Ui for admin. Logout successfull. Admin not reachable from homepage. Neither is logoutpage. Update for Architecture file.
This commit is contained in:
@@ -1,40 +1,48 @@
|
|||||||
VOYAGE – Blog Architecture Documentation
|
VOYAGE – Blog & Admin Architecture Documentation
|
||||||
========================================
|
===============================================
|
||||||
|
|
||||||
Purpose
|
Purpose
|
||||||
-------
|
-------
|
||||||
This document describes the architecture of the VOYAGE public blog,
|
This document describes the architecture of the VOYAGE public blog
|
||||||
including folder structure, routing, data flow, and rendering logic.
|
and its internal admin system.
|
||||||
|
|
||||||
A new developer should be able to understand how blog posts are loaded,
|
It covers folder structure, routing, data flow, authentication,
|
||||||
render routes work, and where to extend the system after reading this file.
|
and the admin UI shell.
|
||||||
|
|
||||||
|
A new developer should be able to understand:
|
||||||
|
- how blog posts are loaded and rendered
|
||||||
|
- how routing works (public + admin)
|
||||||
|
- how authentication is handled
|
||||||
|
- where and how to extend the system
|
||||||
|
|
||||||
|
|
||||||
High-Level Overview
|
High-Level Overview
|
||||||
-------------------
|
-------------------
|
||||||
The blog is implemented using the **Next.js App Router** with:
|
The VOYAGE public site is implemented using the **Next.js App Router**
|
||||||
|
inside a monorepo, with authentication delegated to a **Spring Boot backend**.
|
||||||
|
|
||||||
- File-based routing
|
Key technologies:
|
||||||
- Dynamic route segments
|
- Next.js (App Router, Server Components)
|
||||||
- MDX-based content
|
- Spring Boot (session-based auth)
|
||||||
- A shared public layout
|
- Tailwind CSS (UI styling)
|
||||||
|
- File-based content (TXT / MD / MDX)
|
||||||
|
|
||||||
Key principles:
|
Core principles:
|
||||||
- Blog content lives outside the app router (content/posts)
|
- Public content is always accessible
|
||||||
- Routing is derived from folder structure
|
- Admin tools are protected via backend session
|
||||||
- Rendering happens in async server components
|
- No duplicate authentication logic
|
||||||
- Shared UI is colocated in components/
|
- Clear separation between public site and admin system
|
||||||
|
|
||||||
|
|
||||||
Monorepo Context
|
Monorepo Context
|
||||||
----------------
|
----------------
|
||||||
This repository is a monorepo containing multiple applications.
|
This repository is a monorepo containing multiple applications.
|
||||||
|
|
||||||
Relevant app for the blog:
|
Relevant apps:
|
||||||
- apps/public-web → Public website & blog
|
- apps/public-web → Public website, blog, and admin UI
|
||||||
|
- apps/workspace-api → Spring Boot backend (auth, API, DB)
|
||||||
|
|
||||||
Other apps (not covered here):
|
Other apps (not covered here):
|
||||||
- workspace-api
|
|
||||||
- workspace-ui
|
- workspace-ui
|
||||||
|
|
||||||
|
|
||||||
@@ -45,32 +53,36 @@ apps/public-web/
|
|||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── (site)/ → Public site route group
|
│ ├── (site)/ → Public site route group
|
||||||
│ │ ├── layout.tsx → Shared layout (TopBar, globals)
|
│ │ ├── layout.tsx → Shared public layout (TopBar, globals)
|
||||||
│ │ ├── page.tsx → Homepage
|
│ │ ├── page.tsx → Homepage
|
||||||
│ │ ├── about/
|
│ │ ├── about/
|
||||||
│ │ │ └── page.tsx → Static About page
|
│ │ │ └── page.tsx → Static About page
|
||||||
│ │ ├── blog/
|
│ │ ├── blog/
|
||||||
│ │ │ ├── layout.tsx → Blog-specific layout (optional)
|
│ │ │ ├── layout.tsx → Blog-specific layout
|
||||||
│ │ │ ├── page.tsx → Blog index (list of posts)
|
│ │ │ ├── page.tsx → Blog index (list of posts)
|
||||||
│ │ │ └── [slug]/
|
│ │ │ └── [slug]/
|
||||||
│ │ │ └── page.tsx → Dynamic blog post page
|
│ │ │ └── page.tsx → Dynamic blog post page
|
||||||
│ │ └── admin/
|
│ │ └── admin/
|
||||||
│ │ └── page.tsx → Admin-only page (protected)
|
│ │ ├── layout.tsx → Admin auth guard (server-side)
|
||||||
|
│ │ ├── page.tsx → Admin dashboard (sidebar + header)
|
||||||
|
│ │ └── posts/
|
||||||
|
│ │ └── page.tsx → Admin posts manager
|
||||||
│ │
|
│ │
|
||||||
│ ├── global.css → Global styles
|
│ ├── global.css → Tailwind + global styles
|
||||||
│ └── layout.tsx → Root layout
|
│ └── layout.tsx → Root layout (imports global.css)
|
||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ └── shell/
|
│ └── shell/
|
||||||
│ └── TopBar.tsx → Shared navigation bar
|
│ └── TopBar.tsx → Shared public navigation bar
|
||||||
│
|
│
|
||||||
├── visuals/
|
├── visuals/
|
||||||
│ └── ImageSphereSketch.tsx → Creative / visual components
|
│ └── ImageSphereSketch.tsx → Creative / visual components
|
||||||
│
|
│
|
||||||
├── content/
|
├── content/
|
||||||
│ └── posts/
|
│ └── posts/
|
||||||
│ ├── YYYY-MM-DD-title.mdx → Blog post source files
|
│ ├── YY-MM-DD-title.txt → Blog post source files (current)
|
||||||
│ └── ...
|
│ ├── YYYY-MM-DD-title.md → (optional / supported)
|
||||||
|
│ └── YYYY-MM-DD-title.mdx → (optional / supported)
|
||||||
│
|
│
|
||||||
├── lib/
|
├── lib/
|
||||||
│ └── posts.ts → Blog data loading & parsing logic
|
│ └── posts.ts → Blog data loading & parsing logic
|
||||||
@@ -87,125 +99,102 @@ Routing Logic
|
|||||||
-------------
|
-------------
|
||||||
Routing is defined entirely by the folder structure.
|
Routing is defined entirely by the folder structure.
|
||||||
|
|
||||||
Static routes:
|
Public routes:
|
||||||
- / → app/(site)/page.tsx
|
- / → app/(site)/page.tsx
|
||||||
- /about → app/(site)/about/page.tsx
|
- /about → app/(site)/about/page.tsx
|
||||||
- /blog → app/(site)/blog/page.tsx
|
- /blog → app/(site)/blog/page.tsx
|
||||||
|
|
||||||
Dynamic routes:
|
|
||||||
- /blog/[slug] → app/(site)/blog/[slug]/page.tsx
|
- /blog/[slug] → app/(site)/blog/[slug]/page.tsx
|
||||||
|
|
||||||
Admin route:
|
Admin routes:
|
||||||
- /admin → app/(site)/admin/page.tsx (protected)
|
- /admin → Admin dashboard (protected)
|
||||||
|
- /admin/posts → Admin post manager (protected)
|
||||||
|
|
||||||
The `[slug]` directory defines a dynamic route parameter
|
Dynamic route parameters:
|
||||||
that is passed to the page component as `params.slug`.
|
- `[slug]` is derived from the filename in `content/posts`
|
||||||
|
|
||||||
|
|
||||||
Data Flow (How a Blog Post Is Rendered)
|
Blog Data Flow (Rendering a Post)
|
||||||
---------------------------------------
|
---------------------------------
|
||||||
|
|
||||||
1. User navigates to:
|
1. User navigates to:
|
||||||
/blog/some-post-slug
|
/blog/some-post-slug
|
||||||
|
|
||||||
2. Next.js resolves the route:
|
2. Next.js resolves:
|
||||||
app/(site)/blog/[slug]/page.tsx
|
app/(site)/blog/[slug]/page.tsx
|
||||||
|
|
||||||
3. The page component receives:
|
3. Page receives:
|
||||||
params.slug
|
params.slug
|
||||||
|
|
||||||
4. The page calls:
|
4. The page calls:
|
||||||
getPostBySlug(slug) from lib/posts.ts
|
getPostBySlug(slug) from lib/posts.ts
|
||||||
|
|
||||||
5. lib/posts.ts:
|
5. lib/posts.ts:
|
||||||
- Reads the corresponding MDX file from content/posts
|
- Reads a file from content/posts
|
||||||
- Parses frontmatter metadata (title, date, etc.)
|
- Supports .txt, .md, .mdx
|
||||||
|
- Strips date prefix from filename
|
||||||
- Returns structured post data
|
- Returns structured post data
|
||||||
|
|
||||||
6. The page component renders:
|
6. Page renders:
|
||||||
- Shared layout (TopBar)
|
- Shared public layout
|
||||||
- Post metadata
|
- Post metadata
|
||||||
- MDX content as React components
|
- Parsed content
|
||||||
|
|
||||||
7. If the slug does not exist:
|
7. If the slug does not exist:
|
||||||
- notFound() is triggered
|
- notFound() is triggered
|
||||||
|
|
||||||
|
|
||||||
Core Files Explained
|
Admin System Overview
|
||||||
-------------------
|
---------------------
|
||||||
|
|
||||||
app/(site)/blog/[slug]/page.tsx
|
The admin system is an **internal tool**, not a CMS yet.
|
||||||
--------------------------------
|
|
||||||
- Async Server Component
|
|
||||||
- Receives dynamic params (slug)
|
|
||||||
- Loads post data via lib/posts
|
|
||||||
- Handles invalid slugs with notFound()
|
|
||||||
- Renders full blog post view
|
|
||||||
|
|
||||||
app/(site)/blog/page.tsx
|
Current capabilities:
|
||||||
------------------------
|
- Admin dashboard UI
|
||||||
- Blog index page
|
- Post listing / detection
|
||||||
- Loads all posts via lib/posts
|
- Backend session verification
|
||||||
- Renders list / preview of posts
|
- Logout handling
|
||||||
|
|
||||||
lib/posts.ts
|
Admin UI principles:
|
||||||
-------------
|
- Clean, minimal, internal-tool aesthetic
|
||||||
- Central data access layer for blog content
|
- Sidebar + large system header
|
||||||
- Handles file system access and MDX parsing
|
- No duplicated navigation actions
|
||||||
- Keeps routing and rendering logic clean
|
- Tailwind-based styling
|
||||||
|
|
||||||
content/posts/*.mdx
|
|
||||||
-------------------
|
|
||||||
- Source of truth for blog content
|
|
||||||
- File name defines the slug
|
|
||||||
- Frontmatter stores metadata
|
|
||||||
- Body is rendered as MDX
|
|
||||||
|
|
||||||
components/shell/TopBar.tsx
|
|
||||||
---------------------------
|
|
||||||
- Shared navigation component
|
|
||||||
- Used across all public pages
|
|
||||||
- Ensures consistent layout and navigation
|
|
||||||
|
|
||||||
public/blog/visuals/
|
|
||||||
--------------------
|
|
||||||
- Static images for blog posts
|
|
||||||
- Served directly by Next.js
|
|
||||||
- Referenced in MDX or page components
|
|
||||||
|
|
||||||
|
|
||||||
Admin Authentication & Security
|
Admin Authentication & Security
|
||||||
--------------------------------
|
--------------------------------
|
||||||
|
|
||||||
The blog includes an **admin-only section** used for internal tools
|
Authentication is **not handled by Next.js**.
|
||||||
(e.g. editor preview, drafts, future CMS features).
|
|
||||||
|
|
||||||
Authentication is **not handled by Next.js**, but delegated to the
|
Instead, it is delegated to the Spring Boot backend (`workspace-api`).
|
||||||
existing Spring Boot backend (`workspace-api`).
|
|
||||||
|
|
||||||
Key principles:
|
Key principles:
|
||||||
- Public blog remains fully accessible without login
|
- Single source of truth for auth (Spring)
|
||||||
- Admin routes require a valid backend session
|
|
||||||
- Session-based authentication (JSESSIONID)
|
- Session-based authentication (JSESSIONID)
|
||||||
- No JWT, no duplicate auth system
|
- No JWT
|
||||||
|
- No duplicate auth logic in frontend
|
||||||
|
- Admin routes require a valid backend session
|
||||||
|
|
||||||
|
|
||||||
Admin Route Protection (Frontend)
|
Admin Route Protection (Frontend)
|
||||||
---------------------------------
|
---------------------------------
|
||||||
|
|
||||||
Route:
|
Protection is implemented in:
|
||||||
- /admin → app/(site)/admin/page.tsx
|
|
||||||
|
|
||||||
Protection strategy:
|
app/(site)/admin/layout.tsx
|
||||||
- Implemented as an **async Server Component guard**
|
|
||||||
- On each request:
|
Strategy:
|
||||||
|
- Admin layout is an async Server Component
|
||||||
|
- On every request:
|
||||||
- Calls backend endpoint `/api/me`
|
- Calls backend endpoint `/api/me`
|
||||||
- Forwards cookies manually
|
- Forwards incoming cookies manually
|
||||||
- If response is 401 → redirect to /login
|
- If response is 401 → redirect to /login
|
||||||
- If response is 200 → render admin UI
|
- If response is 200 → render admin UI
|
||||||
|
- Optional role check (ROLE_ADMIN)
|
||||||
|
|
||||||
Important detail:
|
Important technical detail:
|
||||||
- Server-side fetch must forward cookies explicitly
|
- Server-side fetch MUST forward cookies explicitly
|
||||||
|
- Using headers().get("cookie") (async)
|
||||||
- credentials: "include" is NOT sufficient in Server Components
|
- credentials: "include" is NOT sufficient in Server Components
|
||||||
|
|
||||||
|
|
||||||
@@ -215,30 +204,30 @@ Login Flow (End-to-End)
|
|||||||
1. User navigates to:
|
1. User navigates to:
|
||||||
http://localhost:3000/admin
|
http://localhost:3000/admin
|
||||||
|
|
||||||
2. Admin page fetches:
|
2. Admin layout fetches:
|
||||||
GET http://localhost:8080/api/me
|
GET http://localhost:8080/api/me
|
||||||
|
|
||||||
3. If not authenticated:
|
3. If not authenticated:
|
||||||
- Backend returns 401
|
- Backend returns 401
|
||||||
- Next.js redirects to /login (frontend route)
|
- Next.js redirects to /login (frontend)
|
||||||
|
|
||||||
4. Frontend /login page redirects to backend:
|
4. Frontend /login redirects to backend:
|
||||||
GET http://localhost:8080/login-redirect?redirect=http://localhost:3000/admin
|
GET http://localhost:8080/login-redirect?redirect=http://localhost:3000/admin
|
||||||
|
|
||||||
5. Backend:
|
5. Backend:
|
||||||
- Stores redirect target in session
|
- Stores redirect target in session
|
||||||
- Redirects to /login (without query params)
|
- Redirects to /login (no query params)
|
||||||
|
|
||||||
6. Spring Security default login page is shown
|
6. Spring Security default login page is shown
|
||||||
|
|
||||||
7. User submits credentials
|
7. User submits credentials
|
||||||
|
|
||||||
8. On successful login:
|
8. On successful login:
|
||||||
- Custom successHandler reads redirect from session
|
- Custom success handler reads redirect from session
|
||||||
- User is redirected to:
|
- Redirects user back to:
|
||||||
http://localhost:3000/admin
|
http://localhost:3000/admin
|
||||||
|
|
||||||
9. Admin page loads successfully
|
9. Admin UI loads successfully
|
||||||
|
|
||||||
|
|
||||||
Backend Endpoints Involved
|
Backend Endpoints Involved
|
||||||
@@ -248,7 +237,7 @@ Backend Endpoints Involved
|
|||||||
- Returns current authenticated user
|
- Returns current authenticated user
|
||||||
- 200 → logged in
|
- 200 → logged in
|
||||||
- 401 → not authenticated
|
- 401 → not authenticated
|
||||||
- Never redirects (API-safe)
|
- Never redirects
|
||||||
|
|
||||||
/login
|
/login
|
||||||
- Spring Security default login page
|
- Spring Security default login page
|
||||||
@@ -259,23 +248,43 @@ Backend Endpoints Involved
|
|||||||
- Stores redirect target in session
|
- Stores redirect target in session
|
||||||
- Avoids unsupported query params on /login
|
- Avoids unsupported query params on /login
|
||||||
|
|
||||||
|
/logout
|
||||||
|
- Invalidates session
|
||||||
|
- Clears JSESSIONID cookie
|
||||||
|
|
||||||
|
|
||||||
|
Admin Posts Manager
|
||||||
|
-------------------
|
||||||
|
|
||||||
|
Route:
|
||||||
|
- /admin/posts
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- Reads files from content/posts on the server
|
||||||
|
- Detects available posts (.txt / .md / .mdx)
|
||||||
|
- Derives slugs from filenames
|
||||||
|
- Displays debug info (detected paths, files)
|
||||||
|
- Links to public blog pages
|
||||||
|
|
||||||
|
This is intentionally read-only for now.
|
||||||
|
|
||||||
|
|
||||||
Why This Architecture
|
Why This Architecture
|
||||||
---------------------
|
---------------------
|
||||||
- Single source of truth for authentication (Spring)
|
- Clear separation of concerns
|
||||||
- No duplicate auth logic in frontend
|
- Public content stays simple and fast
|
||||||
- Clean separation:
|
- Admin tools are protected and internal
|
||||||
- Public content → no auth
|
- No auth duplication
|
||||||
- Admin tools → backend session
|
- SSR-safe and production-ready
|
||||||
- Works with SSR and Server Components
|
- Scales cleanly to future CMS features
|
||||||
- Production-ready pattern for multi-app monorepos
|
|
||||||
|
|
||||||
|
|
||||||
How to Extend (Future)
|
How to Extend (Future)
|
||||||
----------------------
|
----------------------
|
||||||
- Admin editor UI
|
- Admin post editor UI
|
||||||
- Draft / preview mode
|
- Draft / preview mode
|
||||||
- Role-based admin features
|
- Role-based admin tools
|
||||||
|
- Publishing workflow
|
||||||
- CMS integration
|
- CMS integration
|
||||||
- Protected preview links
|
- Protected preview links
|
||||||
|
|
||||||
@@ -283,8 +292,9 @@ How to Extend (Future)
|
|||||||
Current Status
|
Current Status
|
||||||
--------------
|
--------------
|
||||||
- App Router fully set up
|
- App Router fully set up
|
||||||
- Dynamic blog slugs working
|
- Public blog routing stable
|
||||||
- Shared public layout integrated
|
- Content loading via filesystem stable
|
||||||
- MDX content loading stable
|
- Tailwind styling active
|
||||||
- Admin auth flow stable and tested
|
- Admin auth flow stable and tested
|
||||||
- Ready for styling, animations, and feature extensions
|
- Admin dashboard + posts manager implemented
|
||||||
|
- Ready for further UX polish and feature extensions
|
||||||
11
apps/public-web/app/(site)/admin/LogoutButton.tsx
Normal file
11
apps/public-web/app/(site)/admin/LogoutButton.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function LogoutButton() {
|
||||||
|
const backend = process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={`${backend}/logout`} method="post">
|
||||||
|
<button type="submit">Logout</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
apps/public-web/app/(site)/admin/layout.tsx
Normal file
31
apps/public-web/app/(site)/admin/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
|
||||||
|
async function cookieHeader() {
|
||||||
|
const h = await headers(); // ✅ await
|
||||||
|
return h.get("cookie") ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const res = await fetch(`${process.env.BACKEND_URL}/api/me`, {
|
||||||
|
headers: { cookie: await cookieHeader() }, // ✅ await here too
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) redirect("/login");
|
||||||
|
if (!res.ok) redirect("/login");
|
||||||
|
|
||||||
|
const me = await res.json();
|
||||||
|
|
||||||
|
const isAdmin =
|
||||||
|
Array.isArray(me?.authorities) &&
|
||||||
|
me.authorities.some((a: any) => a.authority === "ROLE_ADMIN");
|
||||||
|
|
||||||
|
if (!isAdmin) redirect("/");
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -1,38 +1,97 @@
|
|||||||
import { redirect } from "next/navigation";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
|
|
||||||
export default async function AdminPage() {
|
export default function AdminPage() {
|
||||||
const cookieHeader = (await cookies())
|
const backend = process.env.BACKEND_URL ?? "http://localhost:8080";
|
||||||
.getAll()
|
|
||||||
.map(({ name, value }) => `${name}=${value}`)
|
|
||||||
.join("; ");
|
|
||||||
|
|
||||||
const res = await fetch(`${process.env.BACKEND_URL}/api/me`, {
|
|
||||||
headers: { cookie: cookieHeader },
|
|
||||||
cache: "no-store",
|
|
||||||
redirect: "manual", // IMPORTANT: don’t follow to /login
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spring might answer 302 to /login when unauthenticated
|
|
||||||
if (res.status === 401 || res.status === 302) redirect("/login");
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`Failed to load session: ${res.status} ${text.slice(0, 200)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = res.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.includes("application/json")) {
|
|
||||||
// we got HTML (login page) or something else
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const me = await res.json();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<div className="min-h-screen bg-[#F8FAFC] flex gap-10 p-8 font-sans text-slate-900">
|
||||||
<h1>Admin</h1>
|
{/* 1. SINGLE CLEAN SIDEBAR */}
|
||||||
<pre>{JSON.stringify(me, null, 2)}</pre>
|
<aside className="w-64 bg-white border border-slate-200 rounded-2xl flex flex-col sticky top-8 h-[calc(100vh-4rem)]">
|
||||||
</main>
|
<div className="p-8 mb-4">
|
||||||
|
<div className="text-2xl font-black tracking-tighter text-indigo-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* SINGLE LOGOUT AT BOTTOM */}
|
||||||
|
<div className="p-4 border-t border-slate-100">
|
||||||
|
<form action={`${backend}/logout`} method="post">
|
||||||
|
<button className="w-full flex items-center justify-center gap-2 px-4 py-3 text-slate-400 hover:text-red-500 hover:bg-red-50 transition-all rounded-lg text-sm font-bold uppercase tracking-wider">
|
||||||
|
⏻ Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* 2. MAIN CONTENT AREA */}
|
||||||
|
<main className="flex-1 flex flex-col min-w-0 pt-24"> {/* FAT VOYAGE HEADER */}
|
||||||
|
|
||||||
|
<header className="px-12 py-10 bg-white border-b border-slate-200 rounded-2xl"> <div className="max-w-5xl">
|
||||||
|
<Link href="/" className="inline-block">
|
||||||
|
<h1 className="text-8xl md:text-9xl font-[1000] tracking-tighter text-slate-900 uppercase leading-none hover:opacity-90 transition">
|
||||||
|
VOYAGE <span className="text-slate-200">OFFICE</span>
|
||||||
|
</h1>
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-3 mt-6">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-emerald-400/80"></span>
|
||||||
|
<p className="text-[10px] font-mono font-semibold text-slate-300 uppercase tracking-[0.25em] truncate">
|
||||||
|
System Connected: {backend}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 3. SYMMETRICAL DASHBOARD CONTENT */}
|
||||||
|
<div className="p-10 max-w-5xl w-full space-y-8">
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* Main Action Card */}
|
||||||
|
<div className="bg-white p-8 rounded-2xl border border-slate-200 shadow-sm flex flex-col justify-between min-h-[200px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-400 mb-1">Content</h3>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">Post Management</h2>
|
||||||
|
<p className="text-slate-500 mt-2 text-sm leading-relaxed">
|
||||||
|
Review and edit all current voyage publications.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href="/admin/posts" className="mt-6 inline-flex items-center font-bold text-indigo-600 hover:gap-2 transition-all">
|
||||||
|
Manage Posts →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Database Status Card */}
|
||||||
|
<div className="bg-white p-8 rounded-2xl border border-slate-200 shadow-sm flex flex-col justify-between min-h-[200px]">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black uppercase tracking-widest text-slate-400 mb-1">Infrastructure</h3>
|
||||||
|
<h2 className="text-2xl font-bold text-slate-800">SQL Database</h2>
|
||||||
|
<p className="text-slate-500 mt-2 text-sm leading-relaxed truncate">
|
||||||
|
Status: Operational at {backend}/h2-console
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a href={`${backend}/h2-console`} target="_blank" className="mt-6 inline-flex items-center font-bold text-indigo-600 hover:gap-2 transition-all">
|
||||||
|
Open Console ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Large Summary Box */}
|
||||||
|
<div className="bg-indigo-600 rounded-3xl p-10 text-white shadow-xl shadow-indigo-200">
|
||||||
|
<h3 className="text-indigo-200 text-xs font-black uppercase tracking-widest mb-2">Office Overview</h3>
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-end gap-6">
|
||||||
|
<div className="max-w-md">
|
||||||
|
<p className="text-2xl font-medium leading-tight italic">
|
||||||
|
"Efficiency is doing things right; effectiveness is doing the right things."
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-4xl font-black">100%</p>
|
||||||
|
<p className="text-indigo-200 text-xs font-bold uppercase">System Uptime</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
123
apps/public-web/app/(site)/admin/posts/page.tsx
Normal file
123
apps/public-web/app/(site)/admin/posts/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type Post = {
|
||||||
|
file: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PostsResult = {
|
||||||
|
postsDir: string;
|
||||||
|
tried: string[];
|
||||||
|
allFiles: string[];
|
||||||
|
posts: Post[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePostsDir(): { postsDir: string; tried: string[] } {
|
||||||
|
const tried: string[] = [];
|
||||||
|
|
||||||
|
// 1) When running `npm run dev` inside apps/public-web
|
||||||
|
const candidate1 = path.join(process.cwd(), "content", "posts");
|
||||||
|
tried.push(candidate1);
|
||||||
|
if (fs.existsSync(candidate1)) return { postsDir: candidate1, tried };
|
||||||
|
|
||||||
|
// 2) When running Next from monorepo root (process.cwd() == repo root)
|
||||||
|
const candidate2 = path.join(process.cwd(), "apps", "public-web", "content", "posts");
|
||||||
|
tried.push(candidate2);
|
||||||
|
if (fs.existsSync(candidate2)) return { postsDir: candidate2, tried };
|
||||||
|
|
||||||
|
// 3) Fallback: try relative to this file (best-effort)
|
||||||
|
const candidate3 = path.join(process.cwd(), "..", "content", "posts");
|
||||||
|
tried.push(candidate3);
|
||||||
|
if (fs.existsSync(candidate3)) return { postsDir: candidate3, tried };
|
||||||
|
|
||||||
|
return { postsDir: candidate1, tried };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPosts(): PostsResult {
|
||||||
|
const { postsDir, tried } = resolvePostsDir();
|
||||||
|
|
||||||
|
if (!fs.existsSync(postsDir)) {
|
||||||
|
return { postsDir, tried, allFiles: [], posts: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const allFiles = fs.readdirSync(postsDir);
|
||||||
|
|
||||||
|
const supported = allFiles.filter((f) =>
|
||||||
|
f.toLowerCase().endsWith(".txt") ||
|
||||||
|
f.toLowerCase().endsWith(".md") ||
|
||||||
|
f.toLowerCase().endsWith(".mdx")
|
||||||
|
);
|
||||||
|
|
||||||
|
const posts = supported.map((file) => {
|
||||||
|
const base = file.replace(/\.(txt|md|mdx)$/i, "");
|
||||||
|
|
||||||
|
// Strip date prefix (YY-MM-DD- or YYYY-MM-DD-)
|
||||||
|
const slug = base
|
||||||
|
.replace(/^\d{2}-\d{2}-\d{2}-/, "")
|
||||||
|
.replace(/^\d{4}-\d{2}-\d{2}-/, "");
|
||||||
|
|
||||||
|
return { file, slug };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { postsDir, tried, allFiles, posts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminPostsPage() {
|
||||||
|
const { postsDir, tried, allFiles, posts } = getPosts();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={{ maxWidth: 900 }}>
|
||||||
|
<h1>Posts</h1>
|
||||||
|
|
||||||
|
<p style={{ opacity: 0.8 }}>
|
||||||
|
<Link href="/admin">← Back to Admin</Link>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<section
|
||||||
|
style={{
|
||||||
|
padding: 12,
|
||||||
|
border: "1px solid #ddd",
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<strong>postsDir:</strong> {postsDir}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>files in folder:</strong> {allFiles.length}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>posts detected:</strong> {posts.length}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details style={{ marginTop: 8 }}>
|
||||||
|
<summary>Debug: paths tried</summary>
|
||||||
|
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{tried.join("\n")}</pre>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{allFiles.length > 0 && (
|
||||||
|
<details style={{ marginTop: 8 }}>
|
||||||
|
<summary>Debug: files seen</summary>
|
||||||
|
<pre style={{ margin: 0, whiteSpace: "pre-wrap" }}>{allFiles.join("\n")}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p>No posts found.</p>
|
||||||
|
) : (
|
||||||
|
<ul>
|
||||||
|
{posts.map((post) => (
|
||||||
|
<li key={post.file}>
|
||||||
|
<Link href={`/blog/${post.slug}`}>{post.slug}</Link>
|
||||||
|
<span style={{ opacity: 0.6 }}> ({post.file})</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
995
apps/public-web/node_modules/.package-lock.json
generated
vendored
995
apps/public-web/node_modules/.package-lock.json
generated
vendored
File diff suppressed because it is too large
Load Diff
5
apps/public-web/package-lock.json
generated
5
apps/public-web/package-lock.json
generated
@@ -1031,6 +1031,7 @@
|
|||||||
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1181,6 +1182,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
@@ -1925,6 +1927,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.11",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
@@ -1946,6 +1949,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -1955,6 +1959,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
#FileLock
|
|
||||||
#Tue Jan 27 18:25:29 CET 2026
|
|
||||||
hostName=macbook-air-von-melika.fritz.box
|
|
||||||
id=19c007d24ac0d06a483d34679bbb29160d6d33ed895
|
|
||||||
method=file
|
|
||||||
server=192.168.178.68\:58736
|
|
||||||
Binary file not shown.
Reference in New Issue
Block a user