first commit
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
|
||||
function LoginInner() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const from = params.get('from') || '/admin';
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/login', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
if (res.ok) {
|
||||
router.replace(from);
|
||||
router.refresh();
|
||||
} else {
|
||||
setError('Incorrect password.');
|
||||
setBusy(false);
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong. Try again.');
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div dir="ltr" className="flex min-h-screen items-center justify-center px-5">
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 -z-10 bg-radial-aurora opacity-50"
|
||||
/>
|
||||
<form
|
||||
onSubmit={submit}
|
||||
className="w-full max-w-sm rounded-2xl border border-white/10 bg-base-900/70 p-8 backdrop-blur-xl"
|
||||
>
|
||||
<div className="mb-6 flex items-center gap-3">
|
||||
<span className="grid h-10 w-10 place-items-center rounded-xl bg-electric/15 font-mono text-sm font-bold text-electric">
|
||||
SA
|
||||
</span>
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-white">Content CMS</h1>
|
||||
<p className="font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
|
||||
soroushasadi.ir
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="mb-1.5 block font-mono text-[0.68rem] uppercase tracking-wider text-slate-400">
|
||||
Admin password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full rounded-lg border border-white/10 bg-base-900/60 px-3 py-2.5 text-sm text-slate-100 outline-none focus:border-electric/60"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-magenta">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy || !password}
|
||||
className="mt-5 w-full rounded-lg bg-electric px-4 py-2.5 text-sm font-semibold text-base-900 transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<LoginInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Link from 'next/link';
|
||||
import { AdminShell } from '@/components/admin/AdminShell';
|
||||
import { EDITABLE_SECTIONS } from '@/lib/content/sections';
|
||||
import { sectionStatus } from '@/lib/db/store';
|
||||
import { passwordConfigured } from '@/lib/auth/session';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 60) return 'just now';
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
export default function AdminDashboard() {
|
||||
const status = sectionStatus();
|
||||
const usingDefaultPassword = !process.env.ADMIN_PASSWORD && passwordConfigured();
|
||||
const edited = Object.keys(status).length;
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{usingDefaultPassword && (
|
||||
<div className="mb-6 rounded-xl border border-magenta/30 bg-magenta/5 p-4 text-sm text-magenta">
|
||||
<strong>Heads up:</strong> no <code className="font-mono">ADMIN_PASSWORD</code> is set, so the dev default
|
||||
(<code className="font-mono">admin</code>) is in use. Set one in your environment before going live.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{EDITABLE_SECTIONS.map((s) => {
|
||||
const edited = status[s.key];
|
||||
return (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={`/admin/sections/${s.key}`}
|
||||
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-white group-hover:text-electric">
|
||||
{s.label.en}
|
||||
<span className="ms-2 font-fa text-sm font-normal text-slate-500">
|
||||
{s.label.fa}
|
||||
</span>
|
||||
</h2>
|
||||
{edited ? (
|
||||
<span className="rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
|
||||
edited · {timeAgo(edited)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400">{s.desc.en}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="group rounded-xl border border-violet/20 bg-violet/[0.03] p-5 transition-colors hover:border-violet/40 hover:bg-violet/[0.06]"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-white group-hover:text-violet">
|
||||
Journal articles
|
||||
<span className="ms-2 font-fa text-sm font-normal text-slate-500">مقالات</span>
|
||||
</h2>
|
||||
<span className="font-mono text-[0.65rem] uppercase tracking-wider text-violet">
|
||||
bodies →
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-slate-400">
|
||||
Edit the full bilingual body of each blog post (lead + content blocks).
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AdminShell } from '@/components/admin/AdminShell';
|
||||
import { PostEditor } from '@/components/admin/PostEditor';
|
||||
import type { JsonValue } from '@/components/admin/JsonForm';
|
||||
import { loadContent } from '@/lib/content/load';
|
||||
import { loadPost, loadPostOverrides, isKnownSlug } from '@/lib/content/posts-store';
|
||||
|
||||
// Always render on demand so the editor mirrors current DB state.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function AdminPostEditorPage({ params }: { params: { slug: string } }) {
|
||||
const { slug } = params;
|
||||
if (!isKnownSlug(slug)) notFound();
|
||||
|
||||
const post = loadPost(slug);
|
||||
if (!post) notFound();
|
||||
|
||||
const overridden = slug in loadPostOverrides();
|
||||
const { en } = loadContent();
|
||||
const title = en.blog.items.find((p) => p.slug === slug)?.title ?? slug;
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
|
||||
>
|
||||
← Journal articles
|
||||
</Link>
|
||||
<h1 className="mb-1 mt-3 text-2xl font-bold text-white">{title}</h1>
|
||||
<p className="mb-6 text-sm text-slate-400">
|
||||
Edit the lead and body blocks for both languages, then save. Changes go live immediately.
|
||||
</p>
|
||||
|
||||
<PostEditor
|
||||
slug={slug}
|
||||
title={title}
|
||||
initial={{
|
||||
date: post.date as JsonValue,
|
||||
accent: post.accent as JsonValue,
|
||||
fa: post.fa as unknown as JsonValue,
|
||||
en: post.en as unknown as JsonValue,
|
||||
}}
|
||||
isOverridden={overridden}
|
||||
/>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import Link from 'next/link';
|
||||
import { AdminShell } from '@/components/admin/AdminShell';
|
||||
import { loadContent } from '@/lib/content/load';
|
||||
import { loadAllPosts, loadPostOverrides } from '@/lib/content/posts-store';
|
||||
|
||||
// Always reflect live DB state in the editor list.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const posts = loadAllPosts();
|
||||
const overrides = loadPostOverrides();
|
||||
const { en } = loadContent();
|
||||
const cardBySlug = new Map<string, (typeof en.blog.items)[number]>(
|
||||
en.blog.items.map((p) => [p.slug, p]),
|
||||
);
|
||||
const slugs = Object.keys(posts);
|
||||
const editedCount = Object.keys(overrides).length;
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-white">Journal articles</h1>
|
||||
<p className="mt-1 text-sm text-slate-400">
|
||||
Edit the full bilingual body of each post.{' '}
|
||||
{editedCount > 0
|
||||
? `${editedCount} article${editedCount > 1 ? 's' : ''} customized.`
|
||||
: 'All articles are at their defaults.'}{' '}
|
||||
Titles, excerpts and read time live under the{' '}
|
||||
<Link href="/admin/sections/blog" className="text-electric hover:underline">
|
||||
Journal
|
||||
</Link>{' '}
|
||||
section.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{slugs.map((slug) => {
|
||||
const card = cardBySlug.get(slug);
|
||||
const post = posts[slug];
|
||||
const edited = slug in overrides;
|
||||
return (
|
||||
<Link
|
||||
key={slug}
|
||||
href={`/admin/posts/${slug}`}
|
||||
className="group rounded-xl border border-white/8 bg-white/[0.02] p-5 transition-colors hover:border-electric/30 hover:bg-electric/[0.03]"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h2 className="font-semibold leading-snug text-white group-hover:text-electric">
|
||||
{card?.title ?? slug}
|
||||
</h2>
|
||||
{edited ? (
|
||||
<span className="shrink-0 rounded-full border border-emerald/30 bg-emerald/5 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-emerald">
|
||||
edited
|
||||
</span>
|
||||
) : (
|
||||
<span className="shrink-0 rounded-full border border-white/10 px-2 py-0.5 font-mono text-[0.6rem] uppercase tracking-wider text-slate-500">
|
||||
default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-3 font-mono text-[0.65rem] uppercase tracking-wider text-slate-500">
|
||||
<span>{card?.category ?? '—'}</span>
|
||||
<span>·</span>
|
||||
<span>{post.date}</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { AdminShell } from '@/components/admin/AdminShell';
|
||||
import { SectionEditor } from '@/components/admin/SectionEditor';
|
||||
import type { JsonValue } from '@/components/admin/JsonForm';
|
||||
import { isEditableKey, sectionLabel } from '@/lib/content/sections';
|
||||
import { loadSection } from '@/lib/content/load';
|
||||
import { getSection } from '@/lib/db/store';
|
||||
|
||||
// Always render on demand: the editor must reflect the current DB state, and
|
||||
// generateStaticParams would otherwise bake build-time defaults into the page.
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function SectionEditorPage({ params }: { params: { key: string } }) {
|
||||
const { key } = params;
|
||||
if (!isEditableKey(key)) notFound();
|
||||
|
||||
const data = loadSection(key);
|
||||
const label = sectionLabel(key);
|
||||
const isOverridden = getSection(key) !== null;
|
||||
|
||||
return (
|
||||
<AdminShell>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Link
|
||||
href="/admin"
|
||||
className="font-mono text-[0.7rem] uppercase tracking-wider text-slate-500 transition-colors hover:text-electric"
|
||||
>
|
||||
← Dashboard
|
||||
</Link>
|
||||
<h1 className="mt-3 text-2xl font-bold text-white">
|
||||
{label.en}
|
||||
<span className="ms-2 font-fa text-lg font-normal text-slate-500">
|
||||
{label.fa}
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mb-6 mt-1 text-sm text-slate-400">
|
||||
Edit both languages with the FA / EN tabs, then save. Changes go live immediately.
|
||||
</p>
|
||||
|
||||
<SectionEditor
|
||||
sectionKey={key}
|
||||
title={label.en}
|
||||
initial={{ fa: data.fa as JsonValue, en: data.en as JsonValue }}
|
||||
isOverridden={isOverridden}
|
||||
/>
|
||||
</div>
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Admin · Content CMS',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
|
||||
// Admin chrome is independent of the public LocaleProvider/Navbar; the
|
||||
// route-group split means these pages never inherit the marketing layout.
|
||||
export default function AdminRootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="min-h-screen bg-base-900">{children}</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user