From 555a75e326663a249cc36a9c6fb047672db8531a Mon Sep 17 00:00:00 2001
From: AliameenXBT
Date: Sun, 3 May 2026 23:01:37 +0100
Subject: [PATCH 1/4] feat: updates, recognition, performance and cleanup
Schema:
- Added show_in_recognition column to blog_posts
- Added placement column to blog_posts
- Generated and pushed migrations to Neon
Features:
- Recognition section on home page (curated, admin-controlled)
- show_in_recognition toggle in admin blog form
- placement field in admin blog form (shown when recognition is on)
- Blog post editorial layout with full width cover image
- Reading time calculation from TipTap JSON content
- Latest Releases section pulling all categories
Performance:
- Cloudinary transformation helpers (getBlogCoverUrl etc)
- getOptimisedUrl() with context-specific transforms
- g_face crop on team photos
- Remaining ISR revalidate=3600 on public pages
- Full OG and Twitter metadata on all pages
- robots.ts and sitemap.ts
Cleanup:
- HackathonStrip component removed
- Inline SVG icons via shared icons.tsx
- No emoji usage in UI components
- Image upload folder routing (?folder= param)
- All content images using Cloudinary helpers
---
.github/pull_request_template.md | 2 +-
.gitignore | 6 +
AGENTS.md | 166 +++--
CLAUDE.md | 566 -----------------
src/app/(public)/blog/[slug]/page.tsx | 97 ++-
src/app/(public)/careers/page.tsx | 6 +-
src/app/(public)/page.tsx | 40 +-
src/app/(public)/products/[slug]/page.tsx | 13 +-
src/app/(public)/products/page.tsx | 11 +-
src/app/(public)/team/page.tsx | 52 +-
src/app/admin/blog/[id]/page.tsx | 15 +
src/app/admin/blog/page.tsx | 11 +-
src/app/api/admin/blog/[id]/route.ts | 2 +
src/app/api/admin/blog/route.ts | 2 +
src/app/api/upload/route.ts | 27 +-
src/components/admin/ImageUpload.tsx | 25 +-
src/components/admin/ResourceForms.tsx | 53 +-
src/components/admin/RichTextEditor.tsx | 2 +-
src/components/blog/PostContent.tsx | 16 +-
src/components/blog/UpdatesList.tsx | 12 +-
src/components/layout/Footer.tsx | 51 +-
src/components/layout/Navbar.tsx | 11 +-
src/components/sections/HackathonStrip.tsx | 29 -
.../sections/LatestReleasesSection.tsx | 12 +-
src/components/sections/ProductsSection.tsx | 9 +-
.../sections/RecognitionSection.tsx | 119 ++++
src/components/ui/Button.tsx | 2 +-
src/components/ui/icons.tsx | 307 +++++++++
src/db/migrations/0002_useful_star_brand.sql | 1 +
.../migrations/0003_daffy_thaddeus_ross.sql | 1 +
src/db/migrations/meta/0002_snapshot.json | 579 +++++++++++++++++
src/db/migrations/meta/0003_snapshot.json | 585 ++++++++++++++++++
src/db/migrations/meta/_journal.json | 14 +
src/db/queries.ts | 78 +++
src/db/schema.ts | 2 +
src/lib/cloudinary.ts | 34 +-
src/lib/utils.ts | 8 +
src/types/index.ts | 6 +-
38 files changed, 2090 insertions(+), 882 deletions(-)
delete mode 100644 CLAUDE.md
delete mode 100644 src/components/sections/HackathonStrip.tsx
create mode 100644 src/components/sections/RecognitionSection.tsx
create mode 100644 src/components/ui/icons.tsx
create mode 100644 src/db/migrations/0002_useful_star_brand.sql
create mode 100644 src/db/migrations/0003_daffy_thaddeus_ross.sql
create mode 100644 src/db/migrations/meta/0002_snapshot.json
create mode 100644 src/db/migrations/meta/0003_snapshot.json
create mode 100644 src/db/queries.ts
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index ecd0043..1cb3e3c 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -9,7 +9,7 @@
- [ ] Docs
## Checklist
-- [ ] I have read CLAUDE.md
+- [ ] I have read AGENTS.md
- [ ] pnpm build passes locally with no errors
- [ ] No TypeScript errors (pnpm tsc --noEmit)
- [ ] No hardcoded secrets or API keys
diff --git a/.gitignore b/.gitignore
index 12e2611..f548d20 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,9 @@ coverage/
.turbo/
drizzle/
*.tsbuildinfo
+
+# AI assistant local files — do not commit
+CLAUDE.md
+GEMINI.md
+.cursorrules
+PROMPTS.md
diff --git a/AGENTS.md b/AGENTS.md
index 9726cad..89237b3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -121,6 +121,33 @@ Logo files in `public/logos/`:
- Never recreate the logo in code. Always use the actual files.
- Navbar logo always links to `/`
+### Icons
+
+- **Never install an icon library** (no lucide-react, heroicons package, react-icons, etc.)
+- All icons are **inline SVGs** written directly in the component
+- To find icon SVG paths: browse heroicons.com or lucide.dev, copy the raw SVG code only — not the package import
+- All inline SVGs: width 24, height 24, stroke="currentColor" or fill="#121F38"
+- Never use emojis as UI icons — always use inline SVGs
+
+```tsx
+// CORRECT — inline SVG
+function TrophyIcon() {
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+// WRONG — never do this
+import { Trophy } from 'lucide-react'
+```
+
---
## 4. Folder Structure
@@ -187,11 +214,11 @@ codeddevs-website/
│ │ │ ├── RecognitionSection.tsx
│ │ │ └── TeamSection.tsx
│ │ ├── blog/
-│ │ │ └── PostContent.tsx # TipTap read-only renderer
+│ │ │ └── PostContent.tsx
│ │ ├── careers/
│ │ │ └── ApplicationForm.tsx
-│ │ └── contact/
-│ │ └── ContactForm.tsx
+│ │ ├── contact/
+│ │ │ └── ContactForm.tsx
│ │ └── admin/
│ │ ├── RichTextEditor.tsx
│ │ ├── ImageUpload.tsx
@@ -199,6 +226,7 @@ codeddevs-website/
│ ├── db/
│ │ ├── index.ts
│ │ ├── schema.ts
+│ │ ├── queries.ts
│ │ └── migrations/
│ ├── lib/
│ │ ├── auth.ts
@@ -214,7 +242,7 @@ codeddevs-website/
├── tsconfig.json
├── .env.local
├── .env.example
-├── CLAUDE.md
+├── AGENTS.md
└── package.json
```
@@ -239,11 +267,21 @@ order_index, created_at, updated_at
```ts
id, title, slug, excerpt, content (json — TipTap),
cover_url, author, category, is_published,
-show_in_recognition, published_at, created_at, updated_at
+show_in_recognition, placement, published_at,
+created_at, updated_at
```
**category enum:** `'Product Update' | 'Announcement' | 'Roadmap' | 'Story'`
-**show_in_recognition:** `boolean, notNull, default(false)` — controls whether post appears in the Recognition section on the home page. Admin toggles this manually per post.
+
+**show_in_recognition:** `boolean, notNull, default(false)`
+Controls whether post appears in the Recognition section on the home page.
+Admin toggles this manually per post.
+
+**placement:** `text, nullable`
+Controls the placement badge shown on the Recognition card.
+Values: `'1st' | '2nd' | '3rd' | 'winner' | null`
+Only relevant when show_in_recognition is true.
+Displayed as an inline SVG icon + label — never as an emoji.
### careers
```ts
@@ -251,10 +289,13 @@ id, title, type, location, description,
requirements, is_open, created_at, updated_at
```
+**type enum:** `'full-time' | 'contract' | 'volunteer'`
+
### career_applications
```ts
-id, career_id (→ careers.id), full_name, email,
-portfolio_url, github_url, cover_letter, status, created_at
+id, career_id (→ careers.id onDelete cascade),
+full_name, email, portfolio_url, github_url,
+cover_letter, status, created_at
```
**status enum:** `'pending' | 'reviewed' | 'rejected'`
@@ -286,6 +327,9 @@ RESEND_API_KEY=
CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com
```
+Use `DATABASE_URL` for all app queries.
+Use `DATABASE_URL_UNPOOLED` only in `drizzle.config.ts` for migrations.
+
---
## 7. API Routes
@@ -299,7 +343,7 @@ CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com
### Admin (401 if no session)
| Method | Route | Description |
|---|---|---|
-| POST | `/api/upload` | Upload to Cloudinary |
+| POST | `/api/upload?folder=[folder]` | Upload to Cloudinary in correct subfolder |
| GET/POST | `/api/admin/team` | List / create |
| GET/PUT/DELETE | `/api/admin/team/[id]` | Read / update / delete |
| GET/POST | `/api/admin/products` | List / create |
@@ -313,6 +357,15 @@ CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com
| GET | `/api/admin/messages` | List |
| PUT/DELETE | `/api/admin/messages/[id]` | Mark read / delete |
+### Upload folder routing
+The `/api/upload` route accepts a `?folder=` query param:
+- Team photos → `?folder=team`
+- Product covers → `?folder=products`
+- Blog covers → `?folder=blogs`
+- Inline blog images → `?folder=blogs/inline`
+
+All uploads go to `codeddevs-website/[folder]/` in Cloudinary.
+
---
## 8. Route Protection
@@ -335,6 +388,7 @@ Five sections in order:
- Headline: "Engineering Software That Works for Africa"
- Subtext: "We build AI-first software products for African markets — from first principles, not adaptations."
- CTAs: "See Our Products" → /products | "Get in Touch" → /contact
+- Kody mascot (kodyfigma.svg — neutral) featured in hero
**2. Products Section**
- Heading: "What We're Building"
@@ -354,13 +408,15 @@ Five sections in order:
- Heading: "Recognition"
- Fetches blog posts where `show_in_recognition = true` AND `is_published = true`
- Ordered by published_at DESC, limit 3
-- Cards (Option B style — no images):
- - Placement badge: 🥇 1st Place / 🥉 3rd Place
- - Blog post title
- - Excerpt (short)
- - Date
+- Cards — text only, no cover image:
+ - Inline SVG placement icon + placement label (from `placement` field)
+ - Category badge
+ - Blog post title (JetBrains Mono, bold)
+ - Excerpt (IBM Plex Sans, 2 lines max, truncated)
+ - Date (formatted, muted, small)
- "Read the story →" → links to /blog/[slug]
-- This is curated — admin manually toggles show_in_recognition on specific posts
+- Placement display uses inline SVG icons — never emojis
+- This is curated — admin manually toggles show_in_recognition per post
- If no recognition posts exist, section does not render
**5. About Teaser**
@@ -372,7 +428,7 @@ Five sections in order:
- Company facts: RC 9426867 | Lagos, Nigeria | Est. March 2026
### Products (/products)
-- Lists all products from DB
+- Lists all products from DB ordered by order_index
- Each card: name, tagline, status badge
- Links to /products/[slug] (internal) and external_url (external)
@@ -382,28 +438,29 @@ Five sections in order:
- Related blog posts
### Blog (/blog) — displayed as "Updates"
-- URL stays /blog. All labels say "Updates"
+- URL stays /blog. All user-facing labels say "Updates"
- Lists published posts ordered by published_at DESC
- Filterable by: All | Product Update | Announcement | Roadmap | Story
- Each card: category badge, title, excerpt, author, date, dynamic CTA
### Blog Post (/blog/[slug])
Editorial layout:
-```text
-[Cover image — full width, 1200x630px]
+```
+[Cover image — full width, 1200x630px, priority prop for LCP]
CATEGORY BADGE
Title (JetBrains Mono, H1)
-By [author] · [date] · [X min read]
-─────────────────────────────────
-[TipTap rendered content — IBM Plex Sans body]
+By [author] · [formatted date] · [X min read]
+─────────────────────────────────────────────
+[TipTap rendered content — IBM Plex Sans body, max-w-3xl]
```
-- Reading time calculated from word count
-- Cover image rendered at full width
-- Content rendered via PostContent.tsx (TipTap read-only)
+- Cover image: full width, no max-w constraint
+- Article content: max-w-3xl centered for readable line length
+- Reading time calculated from TipTap JSON word count
+- Content rendered via PostContent.tsx (TipTap read-only, 'use client')
### Team (/team)
- Fetches team_members where is_active = true, ordered by order_index
-- Each card: photo (800x800px from Cloudinary), name, role, bio, social links
+- Each card: photo (Cloudinary, g_face crop), name, role, bio, social links
- Founders:
- **Kareem Aliameen — Founder & CEO**
Kareem is the Founder and CEO of CodedDevs Technology LTD, leading the company's strategy, product vision and development, and technical direction. A full-stack engineer working primarily in JavaScript and TypeScript, he is highly skilled at leveraging AI for development, research, and productivity. He brings a background spanning graphic design, digital commerce, and entrepreneurship, and is currently studying at Miva University.
@@ -415,10 +472,12 @@ By [author] · [date] · [X min read]
### Careers (/careers)
- Lists open roles
- Empty state: "No open roles right now. Send us a message." → /contact
-- Application form: inline below role card
+- Application form: inline below role card, 'use client'
### Contact (/contact)
-- Two columns: contact info left, form right
+- Two columns desktop, stacked mobile
+- Left: company info, email, social links
+- Right: contact form
- Subjects: General Inquiry | Partnership | Press | Investment | Other
- Email: codeddevs.team@gmail.com
- Socials: GitHub, X, TikTok, YouTube, Instagram
@@ -433,34 +492,33 @@ By [author] · [date] · [X min read]
- `/public/mascot/kody-smilefigma.svg` — smiling Kody
- `/public/mascot/kodyfigma.svg` — neutral Kody
- `/public/fav-icon/logo.png` — favicon files
-- Nothing else in public/
+- Nothing else goes in public/
### Content images → Cloudinary always
-- Team photos: upload to `codeddevs-website/team/` — 800x800px
-- Product covers: upload to `codeddevs-website/products/` — 1200x630px
-- Blog covers: upload to `codeddevs-website/blogs/` — 1200x630px
+- Team photos: `codeddevs-website/team/` — 800x800px
+- Product covers: `codeddevs-website/products/` — 1200x630px
+- Blog covers: `codeddevs-website/blogs/` — 1200x630px
- Inline article images: 1200x800px
### Cloudinary URL transformations
-The `getOptimisedUrl()` helper in `src/lib/cloudinary.ts` appends
-transformations automatically. Never use raw Cloudinary URLs directly.
+Use helpers from `src/lib/cloudinary.ts`. Never use raw Cloudinary URLs.
```ts
-// Context-specific transformations:
-Blog cover banner: f_auto,q_auto,w_1200,h_630,c_fill
-Recognition card: f_auto,q_auto,w_600,h_315,c_fill
-Blog list thumbnail: f_auto,q_auto,w_800,h_420,c_fill
-Team photo: f_auto,q_auto,w_400,h_400,c_fill,g_face
-Product cover: f_auto,q_auto,w_1200,h_630,c_fill
+getBlogCoverUrl(url) // f_auto,q_auto,w_1200,h_630,c_fill
+getBlogThumbnailUrl(url) // f_auto,q_auto,w_800,h_420,c_fill
+getRecognitionCardUrl(url) // f_auto,q_auto,w_600,h_315,c_fill
+getTeamPhotoUrl(url) // f_auto,q_auto,w_400,h_400,c_fill,g_face
+getProductCoverUrl(url) // f_auto,q_auto,w_1200,h_630,c_fill
```
-`g_face` on team photos tells Cloudinary to focus the crop on the face.
+`g_face` on team photos focuses the crop on the face automatically.
### Image component rules
- Always use Next.js `` for Cloudinary images
-- SVGs from public/ can use `` or ` ` — both fine
+- SVGs from public/ can use `` or ` `
- Never use raw ` ` for content images
- Always set meaningful `alt` text
+- Add `priority` prop to above-the-fold images (hero, blog cover)
---
@@ -502,7 +560,7 @@ const [products, posts] = await Promise.all([
- Fetch full columns only on detail/single pages
### robots.ts and sitemap.ts
-- Block: /admin, /api
+- Block: /admin, /api from crawlers
- Expose all public routes + dynamic product/blog slugs
---
@@ -514,23 +572,25 @@ const [products, posts] = await Promise.all([
3. Auth check first on every admin route — 401 if no session
4. Zod validation on every API route that accepts a body
5. pnpm only — never npm or yarn
-6. Cloudinary for all content images
-7. Resend for all email
+6. Cloudinary for all content images — use transformation helpers
+7. Resend for all email — never nodemailer or sendgrid
8. next/font/google for fonts — no CDN link tags
9. No UI libraries — build from scratch with Tailwind
10. cn() for all conditional classNames
11. No animations — nothing moves
12. Light theme only — no dark: variants
-13. No gradients
+13. No gradients — solid colors only
14. TypeScript strict — no any, no @ts-ignore
15. @/ imports only — no relative ../../ imports
16. Product/external links always target="_blank" rel="noopener noreferrer"
17. migrations/ is read-only — only Drizzle Kit writes here
-18. Logo files only — never recreate in code
-19. "Products" not "Projects" — everywhere
-20. Blog URL /blog, displayed as "Updates" everywhere
+18. Logo files only — never recreate logo in code
+19. "Products" not "Projects" — everywhere in UI, routes, and code
+20. Blog URL /blog, displayed as "Updates" in all user-facing labels
21. Use borders sparingly — prefer spacing and background contrast
-22. Design must feel human, not AI-generated
+22. Design must feel human, not AI-generated — avoid generic layouts
+23. No emojis in UI components — use inline SVG icons only
+24. Never install icon libraries — copy raw SVG paths from heroicons.com or lucide.dev
---
@@ -555,10 +615,10 @@ const [products, posts] = await Promise.all([
## 15. Security
- Never commit secrets — .env.local is gitignored
-- All PRs require review from @onerandomdevv
-- Auth, DB schema, deployment changes need explicit approval
+- All PRs require review from @onerandomdevv before merging
+- Auth, DB schema, deployment changes need explicit human approval
- Never auto-merge agent-generated code
-- Rotate keys immediately if exposed
+- Rotate keys immediately if credentials are exposed
- Security contact: codeddevs.team@gmail.com
---
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index cd878c4..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,566 +0,0 @@
-# CODEDDEVS Website — AI Assistant Instructions
-
-> Read this file before touching any code. Every decision in this project flows from this document.
-
----
-
-## 1. What This Project Is
-
-Official company website for **CODEDDEVS TECHNOLOGY LTD** (RC: 9426867).
-
-- **URL:** codeddevs.com (placeholder until domain is confirmed)
-- **Audience:** Investors, press, and partners — NOT merchants, buyers, or end users
-- **Purpose:** Present CODEDDEVS as a serious, product-driven technology company. Communicate what we are building, what's coming next, and how our products are evolving. Share product updates, releases, version changes, roadmaps, and announcements.
-- **Tone:** Professional, minimal, text-first — like Anthropic.com or Stripe.com
-- **This is NOT a portfolio site.** Do not treat it like a project showcase or personal portfolio. It is an official company website structured the way established tech companies present themselves.
-- **This is NOT the twizrr product site.** twizrr.com is a completely separate codebase and repo. Every mention of twizrr on this site links OUT to twizrr.com.
-
----
-
-## 2. Tech Stack
-
-Do not change any of these without explicit instruction from the user.
-
-| Layer | Choice |
-|---|---|
-| Framework | Next.js 14, App Router, TypeScript |
-| Database | Neon PostgreSQL |
-| ORM | Drizzle ORM |
-| Auth | NextAuth.js v5 (credentials, single admin) |
-| Blog editor | TipTap (rich text, stores JSON) |
-| File storage | Cloudinary |
-| Email | Resend |
-| Fonts | JetBrains Mono + IBM Plex Sans |
-| Hosting | Vercel |
-| Package manager | pnpm — NEVER use npm or yarn |
-
----
-
-## 3. Design System
-
-These values are the single source of truth. Never deviate.
-
-### Brand Colors
-
-```
-Primary Navy: #121F38 — the main brand color
-Brand Silver: #D1D6E0 — the secondary brand color
-```
-
-### Color Palette
-
-```css
---color-bg: #FFFFFF; /* page background — pure white */
---color-surface: #F4F5F8; /* cards, input fields, subtle sections */
---color-surface-2: #D1D6E0; /* dividers, section backgrounds, tags */
---color-border: #C4CAD6; /* all borders */
---color-text-primary: #121F38; /* headings, nav, important text */
---color-text-body: #2C3A52; /* body copy */
---color-text-muted: #6B7896; /* captions, labels, secondary */
---color-accent: #121F38; /* primary buttons, links, highlights */
---color-accent-hover: #1A2D4F; /* button/link hover */
---color-success: #16A34A;
---color-error: #DC2626;
-```
-
-### Typography
-
-Fonts loaded via `next/font/google` in `src/app/layout.tsx`. Never use a ` ` tag or CDN.
-
-| Element | Font | Size | Weight | Line Height |
-|---|---|---|---|---|
-| H1 | JetBrains Mono | 56px | 700 | 1.1 |
-| H2 | JetBrains Mono | 40px | 700 | 1.2 |
-| H3 | JetBrains Mono | 28px | 600 | 1.3 |
-| H4 / Subheading | JetBrains Mono | 20px | 500 | 1.4 |
-| Body large | IBM Plex Sans | 18px | 400 | 1.75 |
-| Body | IBM Plex Sans | 16px | 400 | 1.7 |
-| Small / caption | IBM Plex Sans | 14px | 400 | 1.6 |
-| Label / UI tag | IBM Plex Sans | 12px | 500 | — |
-
-### Spacing
-
-- Base unit: 4px (Tailwind default)
-- Section vertical padding: `py-24` desktop, `py-16` mobile
-- Max content width: `max-w-5xl` (1024px), centered with `mx-auto px-6`
-
-### Component Styles
-
-```
-Navbar: bg-white border-b border-[#C4CAD6], sticky top
-Button primary: bg-[#121F38] text-white hover:bg-[#1A2D4F]
-Button secondary: border border-[#C4CAD6] text-[#121F38] hover:bg-[#F4F5F8]
-Cards: bg-[#F4F5F8] border border-[#C4CAD6] rounded-lg
-Inputs: bg-white border border-[#C4CAD6] text-[#121F38] rounded-md
-Active/selected: bg-[#D1D6E0] text-[#121F38]
-Badge / tag: bg-[#D1D6E0] text-[#121F38]
-Footer: bg-[#F4F5F8] border-t border-[#C4CAD6]
-```
-
-### Design Direction
-
-- **Professional, not generic.** Must feel like a real company website — not AI-generated.
-- **Minimal and clean.** Strong typography, clear messaging, generous whitespace.
-- **Content balance: 70% text, 30% images.**
-- **Light theme only.** No dark mode. No `dark:` Tailwind variants.
-- **No animations.** Nothing moves. No keyframes, no motion libraries.
-- **Minimal hover effects.** Color or opacity changes only.
-- **No UI libraries.** Build everything from scratch with Tailwind.
-- **No gradients.** Solid colors only.
-- **Use borders sparingly.** Prefer spacing and background contrast.
-- **No shadows** except subtle `shadow-sm` on cards where needed.
-- **No visual clutter.** Every element must earn its place.
-- **No generic AI-style layouts.**
-
-### Logo Usage
-
-Logo files in `public/logos/`:
-- **Full logo SVG** (`/public/logos/wordmark.svg`) — Navbar, Footer, formal contexts
-- **Icon-only SVG** (`/public/logos/mark.svg`) — small spaces, favicon, mobile nav
-- **PNG** — favicon only
-- Never recreate the logo in code. Always use the actual files.
-- Navbar logo always links to `/`
-
----
-
-## 4. Folder Structure
-
-```
-codeddevs-website/
-├── src/
-│ ├── app/
-│ │ ├── (public)/
-│ │ │ ├── layout.tsx
-│ │ │ ├── page.tsx # Home
-│ │ │ ├── about/page.tsx
-│ │ │ ├── products/
-│ │ │ │ ├── page.tsx
-│ │ │ │ └── [slug]/page.tsx
-│ │ │ ├── blog/
-│ │ │ │ ├── page.tsx # displayed as "Updates"
-│ │ │ │ └── [slug]/page.tsx
-│ │ │ ├── team/page.tsx
-│ │ │ ├── careers/page.tsx
-│ │ │ └── contact/page.tsx
-│ │ ├── admin/
-│ │ │ ├── layout.tsx
-│ │ │ ├── login/page.tsx
-│ │ │ ├── dashboard/page.tsx
-│ │ │ ├── team/ (page, new, [id])
-│ │ │ ├── products/ (page, new, [id])
-│ │ │ ├── blog/ (page, new, [id])
-│ │ │ ├── careers/ (page, new, [id])
-│ │ │ ├── applications/page.tsx
-│ │ │ └── messages/page.tsx
-│ │ ├── api/
-│ │ │ ├── auth/[...nextauth]/route.ts
-│ │ │ ├── contact/route.ts
-│ │ │ ├── careers/apply/route.ts
-│ │ │ ├── upload/route.ts
-│ │ │ └── admin/
-│ │ │ ├── team/ (route, [id])
-│ │ │ ├── products/ (route, [id])
-│ │ │ ├── blog/ (route, [id])
-│ │ │ ├── careers/ (route, [id])
-│ │ │ ├── applications/ (route, [id])
-│ │ │ └── messages/ (route, [id])
-│ │ ├── layout.tsx
-│ │ ├── not-found.tsx
-│ │ ├── robots.ts
-│ │ ├── sitemap.ts
-│ │ └── globals.css
-│ ├── components/
-│ │ ├── layout/
-│ │ │ ├── Navbar.tsx
-│ │ │ ├── Footer.tsx
-│ │ │ └── AdminSidebar.tsx
-│ │ ├── ui/
-│ │ │ ├── Button.tsx
-│ │ │ ├── Badge.tsx
-│ │ │ ├── Card.tsx
-│ │ │ ├── Input.tsx
-│ │ │ └── Textarea.tsx
-│ │ ├── sections/
-│ │ │ ├── HeroSection.tsx
-│ │ │ ├── ProductsSection.tsx
-│ │ │ ├── LatestReleasesSection.tsx
-│ │ │ ├── RecognitionSection.tsx
-│ │ │ └── TeamSection.tsx
-│ │ ├── blog/
-│ │ │ └── PostContent.tsx # TipTap read-only renderer
-│ │ ├── careers/
-│ │ │ └── ApplicationForm.tsx
-│ │ └── contact/
-│ │ └── ContactForm.tsx
-│ │ └── admin/
-│ │ ├── RichTextEditor.tsx
-│ │ ├── ImageUpload.tsx
-│ │ └── DataTable.tsx
-│ ├── db/
-│ │ ├── index.ts
-│ │ ├── schema.ts
-│ │ └── migrations/
-│ ├── lib/
-│ │ ├── auth.ts
-│ │ ├── email.ts
-│ │ ├── cloudinary.ts
-│ │ └── utils.ts
-│ └── types/
-│ └── index.ts
-├── drizzle.config.ts
-├── middleware.ts
-├── next.config.mjs
-├── tailwind.config.ts
-├── tsconfig.json
-├── .env.local
-├── .env.example
-├── CLAUDE.md
-└── package.json
-```
-
----
-
-## 5. Database Schema
-
-### team_members
-```ts
-id, name, role, bio, photo_url, linkedin_url, github_url,
-twitter_url, order_index, is_active, created_at, updated_at
-```
-
-### products
-```ts
-id, name, slug, tagline, description, cover_url,
-external_url, github_url, status, is_featured,
-order_index, created_at, updated_at
-```
-
-### blog_posts
-```ts
-id, title, slug, excerpt, content (json — TipTap),
-cover_url, author, category, is_published,
-show_in_recognition, published_at, created_at, updated_at
-```
-
-**category enum:** `'Product Update' | 'Announcement' | 'Roadmap' | 'Story'`
-**show_in_recognition:** `boolean, notNull, default(false)` — controls whether post appears in the Recognition section on the home page. Admin toggles this manually per post.
-
-### careers
-```ts
-id, title, type, location, description,
-requirements, is_open, created_at, updated_at
-```
-
-### career_applications
-```ts
-id, career_id (→ careers.id), full_name, email,
-portfolio_url, github_url, cover_letter, status, created_at
-```
-
-**status enum:** `'pending' | 'reviewed' | 'rejected'`
-
-### contact_submissions
-```ts
-id, full_name, email, subject, message, is_read, created_at
-```
-
-### admin_users
-```ts
-id, email, password_hash, created_at
-```
-
----
-
-## 6. Environment Variables
-
-```bash
-DATABASE_URL=
-DATABASE_URL_UNPOOLED=
-NEXTAUTH_SECRET=
-NEXTAUTH_URL=http://localhost:3000
-NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=
-NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=
-CLOUDINARY_API_KEY=
-CLOUDINARY_API_SECRET=
-RESEND_API_KEY=
-CONTACT_NOTIFICATION_EMAIL=codeddevs.team@gmail.com
-```
-
----
-
-## 7. API Routes
-
-### Public
-| Method | Route | Description |
-|---|---|---|
-| POST | `/api/contact` | Save + email notification |
-| POST | `/api/careers/apply` | Save + email notification |
-
-### Admin (401 if no session)
-| Method | Route | Description |
-|---|---|---|
-| POST | `/api/upload` | Upload to Cloudinary |
-| GET/POST | `/api/admin/team` | List / create |
-| GET/PUT/DELETE | `/api/admin/team/[id]` | Read / update / delete |
-| GET/POST | `/api/admin/products` | List / create |
-| GET/PUT/DELETE | `/api/admin/products/[id]` | Read / update / delete |
-| GET/POST | `/api/admin/blog` | List / create |
-| GET/PUT/DELETE | `/api/admin/blog/[id]` | Read / update / delete |
-| GET/POST | `/api/admin/careers` | List / create |
-| GET/PUT/DELETE | `/api/admin/careers/[id]` | Read / update / delete |
-| GET | `/api/admin/applications` | List |
-| PUT | `/api/admin/applications/[id]` | Update status |
-| GET | `/api/admin/messages` | List |
-| PUT/DELETE | `/api/admin/messages/[id]` | Mark read / delete |
-
----
-
-## 8. Route Protection
-
-```ts
-// middleware.ts — uses getToken from next-auth/jwt
-// /api/admin/* + no session → 401 JSON
-// /admin/* + no session → redirect to /admin/login
-// /admin/login + session → redirect to /admin/dashboard
-```
-
----
-
-## 9. Page Content & Structure
-
-### Home (/)
-Five sections in order:
-
-**1. Hero**
-- Headline: "Engineering Software That Works for Africa"
-- Subtext: "We build AI-first software products for African markets — from first principles, not adaptations."
-- CTAs: "See Our Products" → /products | "Get in Touch" → /contact
-
-**2. Products Section**
-- Heading: "What We're Building"
-- Fetches products where is_featured = true
-- Cards: name, tagline, status badge, external link
-
-**3. Latest Releases**
-- Heading: "Latest Releases"
-- Fetches 3 most recent published posts (ALL categories)
-- Cards: title, excerpt, date, category badge, dynamic CTA:
- - "Product Update" → "Read the update →"
- - "Announcement" → "Read the announcement →"
- - "Roadmap" → "Read the roadmap →"
- - "Story" → "Read the story →"
-
-**4. Recognition**
-- Heading: "Recognition"
-- Fetches blog posts where `show_in_recognition = true` AND `is_published = true`
-- Ordered by published_at DESC, limit 3
-- Cards (Option B style — no images):
- - Placement badge: 🥇 1st Place / 🥉 3rd Place
- - Blog post title
- - Excerpt (short)
- - Date
- - "Read the story →" → links to /blog/[slug]
-- This is curated — admin manually toggles show_in_recognition on specific posts
-- If no recognition posts exist, section does not render
-
-**5. About Teaser**
-- 2 sentences about the company
-- "Meet the Team →" → /team
-
-### About (/about)
-- Mission, approach, open-source commitment
-- Company facts: RC 9426867 | Lagos, Nigeria | Est. March 2026
-
-### Products (/products)
-- Lists all products from DB
-- Each card: name, tagline, status badge
-- Links to /products/[slug] (internal) and external_url (external)
-
-### Products — Dedicated Page (/products/[slug])
-- Full product page: name, tagline, description, status, cover image
-- External link + GitHub link
-- Related blog posts
-
-### Blog (/blog) — displayed as "Updates"
-- URL stays /blog. All labels say "Updates"
-- Lists published posts ordered by published_at DESC
-- Filterable by: All | Product Update | Announcement | Roadmap | Story
-- Each card: category badge, title, excerpt, author, date, dynamic CTA
-
-### Blog Post (/blog/[slug])
-Editorial layout:
-```
-[Cover image — full width, 1200x630px]
-CATEGORY BADGE
-Title (JetBrains Mono, H1)
-By [author] · [date] · [X min read]
-─────────────────────────────────
-[TipTap rendered content — IBM Plex Sans body]
-```
-- Reading time calculated from word count
-- Cover image rendered at full width
-- Content rendered via PostContent.tsx (TipTap read-only)
-
-### Team (/team)
-- Fetches team_members where is_active = true, ordered by order_index
-- Each card: photo (800x800px from Cloudinary), name, role, bio, social links
-- Founders:
- - **Kareem Aliameen — Founder & CEO**
- Kareem is the Founder and CEO of CodedDevs Technology LTD, leading the company's strategy, product vision and development, and technical direction. A full-stack engineer working primarily in JavaScript and TypeScript, he is highly skilled at leveraging AI for development, research, and productivity. He brings a background spanning graphic design, digital commerce, and entrepreneurship, and is currently studying at Miva University.
- - **Yusuf Ibrahim Ayinla — Co-Founder & CTO**
- Yusuf is the Co-Founder and CTO of CodedDevs Technology LTD, responsible for the technical architecture across the company's products. A full-stack engineer working in JavaScript and TypeScript, he is highly skilled at leveraging AI for development and research, and is known for his curiosity, depth of thinking, and ability to move quickly across technologies.
- - **Amoo Mustakheem Olamilekan — Co-Founder & COO**
- Mustakheem is the Co-Founder and COO of CodedDevs Technology LTD, leading business development, partnerships, and growth strategy. A full-stack engineer with a background in Node.js and Python, he brings strong skills in networking, outreach, and identifying opportunities.
-
-### Careers (/careers)
-- Lists open roles
-- Empty state: "No open roles right now. Send us a message." → /contact
-- Application form: inline below role card
-
-### Contact (/contact)
-- Two columns: contact info left, form right
-- Subjects: General Inquiry | Partnership | Press | Investment | Other
-- Email: codeddevs.team@gmail.com
-- Socials: GitHub, X, TikTok, YouTube, Instagram
-
----
-
-## 10. Image Strategy
-
-### Static brand assets → `public/` only
-- `/public/logos/wordmark.svg` — full logo
-- `/public/logos/mark.svg` — icon only
-- `/public/mascot/kody-smilefigma.svg` — smiling Kody
-- `/public/mascot/kodyfigma.svg` — neutral Kody
-- `/public/fav-icon/logo.png` — favicon files
-- Nothing else in public/
-
-### Content images → Cloudinary always
-- Team photos: upload to `codeddevs-website/team/` — 800x800px
-- Product covers: upload to `codeddevs-website/products/` — 1200x630px
-- Blog covers: upload to `codeddevs-website/blogs/` — 1200x630px
-- Inline article images: 1200x800px
-
-### Cloudinary URL transformations
-The `getOptimisedUrl()` helper in `src/lib/cloudinary.ts` appends
-transformations automatically. Never use raw Cloudinary URLs directly.
-
-```ts
-// Context-specific transformations:
-Blog cover banner: f_auto,q_auto,w_1200,h_630,c_fill
-Recognition card: f_auto,q_auto,w_600,h_315,c_fill
-Blog list thumbnail: f_auto,q_auto,w_800,h_420,c_fill
-Team photo: f_auto,q_auto,w_400,h_400,c_fill,g_face
-Product cover: f_auto,q_auto,w_1200,h_630,c_fill
-```
-
-`g_face` on team photos tells Cloudinary to focus the crop on the face.
-
-### Image component rules
-- Always use Next.js `` for Cloudinary images
-- SVGs from public/ can use `` or ` ` — both fine
-- Never use raw ` ` for content images
-- Always set meaningful `alt` text
-
----
-
-## 11. Mascot Usage (Kody)
-
-Two SVG variations in `public/mascot/`:
-
-| File | Variant | Use where |
-|---|---|---|
-| `kody-smilefigma.svg` | Smiling | 404 page, empty states, contact page |
-| `kodyfigma.svg` | Neutral/confident | Hero section, careers page |
-
-Rules:
-- Never smaller than 120px
-- Always on white or light surface background
-- Use sparingly and purposefully — not as filler
-- Never recreate in code — always use the SVG files
-
----
-
-## 12. Performance
-
-### ISR — add to all public pages
-```ts
-export const revalidate = 3600 // 1 hour
-```
-
-### Parallel DB queries — always use Promise.all()
-```ts
-const [products, posts] = await Promise.all([
- db.select()...,
- db.select()...
-])
-```
-
-### Selective columns on list pages
-- Blog list: never fetch `content` column (large JSON)
-- Products list: never fetch `description` on list view
-- Fetch full columns only on detail/single pages
-
-### robots.ts and sitemap.ts
-- Block: /admin, /api
-- Expose all public routes + dynamic product/blog slugs
-
----
-
-## 13. Coding Rules
-
-1. Server components by default — `'use client'` only when needed
-2. Drizzle for all DB queries — no raw SQL
-3. Auth check first on every admin route — 401 if no session
-4. Zod validation on every API route that accepts a body
-5. pnpm only — never npm or yarn
-6. Cloudinary for all content images
-7. Resend for all email
-8. next/font/google for fonts — no CDN link tags
-9. No UI libraries — build from scratch with Tailwind
-10. cn() for all conditional classNames
-11. No animations — nothing moves
-12. Light theme only — no dark: variants
-13. No gradients
-14. TypeScript strict — no any, no @ts-ignore
-15. @/ imports only — no relative ../../ imports
-16. Product/external links always target="_blank" rel="noopener noreferrer"
-17. migrations/ is read-only — only Drizzle Kit writes here
-18. Logo files only — never recreate in code
-19. "Products" not "Projects" — everywhere
-20. Blog URL /blog, displayed as "Updates" everywhere
-21. Use borders sparingly — prefer spacing and background contrast
-22. Design must feel human, not AI-generated
-
----
-
-## 14. Company Details
-
-| Field | Value |
-|---|---|
-| Company | CODEDDEVS TECHNOLOGY LTD |
-| RC Number | 9426867 |
-| Incorporated | 18 March 2026 |
-| Location | Lagos, Nigeria |
-| Email | codeddevs.team@gmail.com |
-| GitHub | github.com/coded-devs |
-| X | @CodedDevs |
-| TikTok | @CodedDevs |
-| YouTube | @CodedDevs |
-| Instagram | @codeddevs_ |
-| Main product | twizrr → twizrr.com |
-
----
-
-## 15. Security
-
-- Never commit secrets — .env.local is gitignored
-- All PRs require review from @onerandomdevv
-- Auth, DB schema, deployment changes need explicit approval
-- Never auto-merge agent-generated code
-- Rotate keys immediately if exposed
-- Security contact: codeddevs.team@gmail.com
-
----
-
-*Last updated: May 2026*
\ No newline at end of file
diff --git a/src/app/(public)/blog/[slug]/page.tsx b/src/app/(public)/blog/[slug]/page.tsx
index 55c8336..7547e19 100644
--- a/src/app/(public)/blog/[slug]/page.tsx
+++ b/src/app/(public)/blog/[slug]/page.tsx
@@ -1,14 +1,15 @@
import type { Metadata } from "next";
import Image from "next/image";
import { notFound } from "next/navigation";
-import { cache } from "react";
-import { and, desc, eq } from "drizzle-orm";
+import { desc, eq } from "drizzle-orm";
import PostContent, {
type TiptapJson,
} from "@/components/blog/PostContent";
import Badge from "@/components/ui/Badge";
import { blogPosts, db } from "@/db";
-import { getOptimisedUrl } from "@/lib/cloudinary";
+import { getPostBySlug } from "@/db/queries";
+import { getBlogCoverUrl } from "@/lib/cloudinary";
+import { getReadingTime } from "@/lib/utils";
export const revalidate = 3600;
@@ -34,20 +35,6 @@ function isTiptapJson(value: unknown): value is TiptapJson {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
-const getPublishedPostBySlug = cache(async (slug: string) => {
- try {
- const [post] = await db
- .select()
- .from(blogPosts)
- .where(and(eq(blogPosts.slug, slug), eq(blogPosts.is_published, true)))
- .limit(1);
-
- return post ?? null;
- } catch {
- return null;
- }
-});
-
export async function generateStaticParams() {
if (process.env.CI === "true") {
return [];
@@ -69,20 +56,18 @@ export async function generateStaticParams() {
export async function generateMetadata({
params,
}: UpdatePageProps): Promise {
- const post = await getPublishedPostBySlug(params.slug);
+ const post = await getPostBySlug(params.slug);
if (!post) {
return {
- title: "Update — CodedDevs Updates",
+ title: "Update - CodedDevs Updates",
};
}
- const title = `${post.title} — CodedDevs Updates`;
+ const title = `${post.title} - CodedDevs Updates`;
const description = post.excerpt;
const url = `https://codeddevs.com/blog/${post.slug}`;
- const images = post.cover_url
- ? [{ url: getOptimisedUrl(post.cover_url), alt: post.title }]
- : undefined;
+ const images = post.cover_url ? [post.cover_url] : undefined;
return {
title,
@@ -99,13 +84,13 @@ export async function generateMetadata({
card: "summary_large_image",
title,
description,
- images: images?.map((image) => image.url),
+ images,
},
};
}
export default async function UpdatePage({ params }: UpdatePageProps) {
- const post = await getPublishedPostBySlug(params.slug);
+ const post = await getPostBySlug(params.slug);
if (!post) {
notFound();
@@ -114,46 +99,40 @@ export default async function UpdatePage({ params }: UpdatePageProps) {
const content = isTiptapJson(post.content)
? post.content
: { type: "doc", content: [] };
+ const readingTime = getReadingTime(post.content);
return (
-
-
-
-
-
{post.category}
-
- {post.title}
-
-
- {post.author} - {formatDate(post.published_at)}
-
-
-
-
-
+
{post.cover_url ? (
-
+
+
+
) : null}
-
+
);
diff --git a/src/app/(public)/careers/page.tsx b/src/app/(public)/careers/page.tsx
index 373d227..d10e51d 100644
--- a/src/app/(public)/careers/page.tsx
+++ b/src/app/(public)/careers/page.tsx
@@ -5,6 +5,7 @@ import ApplicationForm from "@/components/careers/ApplicationForm";
import Badge from "@/components/ui/Badge";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
+import { ArrowRightIcon } from "@/components/ui/icons";
import { careers, db } from "@/db";
import type { Career } from "@/types";
@@ -136,7 +137,10 @@ export default async function CareersPage() {
No open roles right now.
- Send us a message anyway →
+
+ Send us a message anyway
+
+
)}
diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx
index 6700faf..5f8fe40 100644
--- a/src/app/(public)/page.tsx
+++ b/src/app/(public)/page.tsx
@@ -1,10 +1,12 @@
import type { Metadata } from "next";
-import { desc, eq } from "drizzle-orm";
+import { eq } from "drizzle-orm";
import HeroSection from "@/components/sections/HeroSection";
-import HackathonStrip from "@/components/sections/HackathonStrip";
import LatestReleasesSection from "@/components/sections/LatestReleasesSection";
import ProductsSection from "@/components/sections/ProductsSection";
-import { blogPosts, db, products } from "@/db";
+import RecognitionSection from "@/components/sections/RecognitionSection";
+import { ArrowRightIcon } from "@/components/ui/icons";
+import { db, products } from "@/db";
+import { getLatestPosts, getRecognitionPosts } from "@/db/queries";
export const revalidate = 3600;
@@ -50,30 +52,11 @@ async function getFeaturedProducts() {
}
}
-async function getLatestPosts() {
- try {
- return await db
- .select({
- id: blogPosts.id,
- title: blogPosts.title,
- slug: blogPosts.slug,
- excerpt: blogPosts.excerpt,
- category: blogPosts.category,
- published_at: blogPosts.published_at,
- })
- .from(blogPosts)
- .where(eq(blogPosts.is_published, true))
- .orderBy(desc(blogPosts.published_at))
- .limit(3);
- } catch {
- return [];
- }
-}
-
export default async function HomePage() {
- const [featuredProducts, latestPosts] = await Promise.all([
+ const [featuredProducts, latestPosts, recognitionPosts] = await Promise.all([
getFeaturedProducts(),
- getLatestPosts(),
+ getLatestPosts(3),
+ getRecognitionPosts(3),
]);
return (
@@ -81,7 +64,7 @@ export default async function HomePage() {
-
+
diff --git a/src/app/(public)/products/[slug]/page.tsx b/src/app/(public)/products/[slug]/page.tsx
index 4d9f719..7704db9 100644
--- a/src/app/(public)/products/[slug]/page.tsx
+++ b/src/app/(public)/products/[slug]/page.tsx
@@ -5,8 +5,9 @@ import { cache } from "react";
import { eq } from "drizzle-orm";
import Badge from "@/components/ui/Badge";
import Button from "@/components/ui/Button";
+import { ExternalLinkIcon, GithubIcon } from "@/components/ui/icons";
import { db, products } from "@/db";
-import { getOptimisedUrl } from "@/lib/cloudinary";
+import { getProductCoverUrl } from "@/lib/cloudinary";
import type { ProductSelect } from "@/types";
export const revalidate = 3600;
@@ -82,7 +83,7 @@ export async function generateMetadata({
const description = product.tagline;
const url = `https://codeddevs.com/products/${product.slug}`;
const images = product.cover_url
- ? [{ url: getOptimisedUrl(product.cover_url), alt: product.name }]
+ ? [{ url: getProductCoverUrl(product.cover_url), alt: product.name }]
: undefined;
return {
@@ -139,7 +140,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
target="_blank"
rel="noopener noreferrer"
>
- Visit {product.name} →
+ Visit {product.name}
+
) : null}
@@ -150,7 +152,8 @@ export default async function ProductPage({ params }: ProductPageProps) {
target="_blank"
rel="noopener noreferrer"
>
- View on GitHub →
+ View on GitHub
+
) : null}
@@ -164,7 +167,7 @@ export default async function ProductPage({ params }: ProductPageProps) {
diff --git a/src/app/(public)/team/page.tsx b/src/app/(public)/team/page.tsx
index 800e880..c2dcede 100644
--- a/src/app/(public)/team/page.tsx
+++ b/src/app/(public)/team/page.tsx
@@ -2,8 +2,9 @@ import type { Metadata } from "next";
import Image from "next/image";
import { asc, eq } from "drizzle-orm";
import Card from "@/components/ui/Card";
+import { GithubIcon, LinkedinIcon, XIcon } from "@/components/ui/icons";
import { db, teamMembers } from "@/db";
-import { getOptimisedUrl } from "@/lib/cloudinary";
+import { getTeamPhotoUrl } from "@/lib/cloudinary";
import type { TeamMember } from "@/types";
export const revalidate = 3600;
@@ -48,57 +49,16 @@ type TeamMemberSummary = Pick<
| "order_index"
>;
-const iconClasses = "h-4 w-4";
-
-function GitHubIcon() {
- return (
-
-
-
- );
-}
-
-function LinkedInIcon() {
- return (
-
-
-
- );
-}
-
-function XIcon() {
- return (
-
-
-
- );
-}
-
function SocialIcon({ icon }: { icon: SocialLink["icon"] }) {
if (icon === "github") {
- return
;
+ return
;
}
if (icon === "linkedin") {
- return
;
+ return
;
}
- return
;
+ return
;
}
function getInitials(name: string) {
@@ -137,7 +97,7 @@ function MemberPhoto({ member }: { member: TeamMemberSummary }) {
return (
diff --git a/src/app/admin/blog/page.tsx b/src/app/admin/blog/page.tsx
index 9339fe0..d5c8e38 100644
--- a/src/app/admin/blog/page.tsx
+++ b/src/app/admin/blog/page.tsx
@@ -1,6 +1,7 @@
import Link from "next/link";
import { desc } from "drizzle-orm";
import AdminDeleteButton from "@/components/admin/AdminDeleteButton";
+import Badge from "@/components/ui/Badge";
import Button from "@/components/ui/Button";
import { blogPosts, db } from "@/db";
import { requireAdminSession } from "@/lib/admin-auth";
@@ -26,6 +27,7 @@ export default async function AdminBlogPage() {
title: blogPosts.title,
category: blogPosts.category,
is_published: blogPosts.is_published,
+ showInRecognition: blogPosts.showInRecognition,
published_at: blogPosts.published_at,
created_at: blogPosts.created_at,
})
@@ -60,7 +62,14 @@ export default async function AdminBlogPage() {
{posts.map((post) => (
- {post.title}
+
+
+ {post.title}
+ {post.showInRecognition ? (
+ ⭐ Recognition
+ ) : null}
+
+
{post.category}
{post.is_published ? "Yes" : "No"}
{formatDate(post.published_at)}
diff --git a/src/app/api/admin/blog/[id]/route.ts b/src/app/api/admin/blog/[id]/route.ts
index 66ca733..c1aa7c1 100644
--- a/src/app/api/admin/blog/[id]/route.ts
+++ b/src/app/api/admin/blog/[id]/route.ts
@@ -22,6 +22,8 @@ const blogPostUpdateSchema = z.object({
cover_url: z.string().url().nullable().optional(),
author: z.string().min(1).optional(),
is_published: z.boolean().optional(),
+ showInRecognition: z.boolean().optional(),
+ placement: z.enum(["1st", "2nd", "3rd", "winner"]).nullable().optional(),
published_at: z.coerce.date().nullable().optional(),
});
diff --git a/src/app/api/admin/blog/route.ts b/src/app/api/admin/blog/route.ts
index 02b90b4..ad71d5f 100644
--- a/src/app/api/admin/blog/route.ts
+++ b/src/app/api/admin/blog/route.ts
@@ -20,6 +20,8 @@ const blogPostCreateSchema = z.object({
cover_url: z.string().url().nullable().optional(),
author: z.string().min(1).optional(),
is_published: z.boolean().optional(),
+ showInRecognition: z.boolean().optional().default(false),
+ placement: z.enum(["1st", "2nd", "3rd", "winner"]).nullable().optional(),
published_at: z.coerce.date().nullable().optional(),
});
diff --git a/src/app/api/upload/route.ts b/src/app/api/upload/route.ts
index 912809d..6184c1c 100644
--- a/src/app/api/upload/route.ts
+++ b/src/app/api/upload/route.ts
@@ -2,6 +2,18 @@ import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { uploadToCloudinary } from "@/lib/cloudinary";
+const allowedFolders = [
+ "team",
+ "products",
+ "blogs",
+ "blogs/inline",
+ "general",
+] as const;
+
+function isAllowedFolder(folder: string): folder is (typeof allowedFolders)[number] {
+ return allowedFolders.includes(folder as (typeof allowedFolders)[number]);
+}
+
export async function POST(request: Request) {
const session = await auth();
@@ -10,6 +22,13 @@ export async function POST(request: Request) {
}
try {
+ const url = new URL(request.url);
+ const folder = url.searchParams.get("folder") || "general";
+
+ if (!isAllowedFolder(folder)) {
+ return NextResponse.json({ error: "Invalid folder" }, { status: 400 });
+ }
+
const formData = await request.formData();
const file = formData.get("file");
@@ -18,9 +37,13 @@ export async function POST(request: Request) {
}
const fileBuffer = Buffer.from(await file.arrayBuffer());
- const url = await uploadToCloudinary(fileBuffer, file.name);
+ const uploadedUrl = await uploadToCloudinary(
+ fileBuffer,
+ file.name,
+ `codeddevs-website/${folder}`,
+ );
- return NextResponse.json({ url }, { status: 200 });
+ return NextResponse.json({ url: uploadedUrl }, { status: 200 });
} catch (error) {
console.error("Upload failed:", error);
return NextResponse.json({ error: "Upload failed" }, { status: 500 });
diff --git a/src/components/admin/ImageUpload.tsx b/src/components/admin/ImageUpload.tsx
index 8261f39..68e4a69 100644
--- a/src/components/admin/ImageUpload.tsx
+++ b/src/components/admin/ImageUpload.tsx
@@ -5,9 +5,17 @@ import { useRef, useState } from "react";
import Button from "@/components/ui/Button";
import { getOptimisedUrl } from "@/lib/cloudinary";
+export type UploadFolder =
+ | "team"
+ | "products"
+ | "blogs"
+ | "blogs/inline"
+ | "general";
+
type ImageUploadProps = {
value: string | null;
onChange: (url: string) => void;
+ folder?: UploadFolder;
};
function getErrorMessage(value: unknown) {
@@ -23,7 +31,11 @@ function getErrorMessage(value: unknown) {
return "Upload failed.";
}
-export default function ImageUpload({ value, onChange }: ImageUploadProps) {
+export default function ImageUpload({
+ value,
+ onChange,
+ folder = "general",
+}: ImageUploadProps) {
const inputRef = useRef(null);
const [isUploading, setIsUploading] = useState(false);
const [error, setError] = useState(null);
@@ -40,10 +52,13 @@ export default function ImageUpload({ value, onChange }: ImageUploadProps) {
const formData = new FormData();
formData.append("file", file);
- const response = await fetch("/api/upload", {
- method: "POST",
- body: formData,
- });
+ const response = await fetch(
+ `/api/upload?folder=${encodeURIComponent(folder)}`,
+ {
+ method: "POST",
+ body: formData,
+ },
+ );
const result: unknown = await response.json();
if (
diff --git a/src/components/admin/ResourceForms.tsx b/src/components/admin/ResourceForms.tsx
index a3d8384..0c818f0 100644
--- a/src/components/admin/ResourceForms.tsx
+++ b/src/components/admin/ResourceForms.tsx
@@ -48,6 +48,8 @@ type BlogFormValues = {
cover_url: string;
author: string;
is_published: boolean;
+ showInRecognition: boolean;
+ placement: "1st" | "2nd" | "3rd" | "winner" | "";
};
type CareerFormValues = {
@@ -190,6 +192,7 @@ export function TeamMemberForm({
setValues({ ...values, role: event.target.value })} required />