commit add78d84604588d19165c5c8898b2dd86b707ba9 Author: soroush.asadi Date: Sun May 31 12:47:02 2026 +0330 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..50bf606 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.next +.git +.gitea +data +npm-debug.log* +.env*.local +.env +.DS_Store +*.tsbuildinfo +README.md +Dockerfile +.dockerignore +docker-compose.yml diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..178c05c --- /dev/null +++ b/.env.local.example @@ -0,0 +1,23 @@ +# Resend API key — get from https://resend.com +RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx + +# Inbox that receives contact-form submissions +CONTACT_INBOX=hello@soroushasadi.ir + +# Verified sender domain on Resend +CONTACT_FROM="Soroush Asadi " + +# --- Admin CMS --------------------------------------------------------------- +# Single password that unlocks the /admin content panel. +# REQUIRED in production (no default is used when NODE_ENV=production). +# In development it falls back to "admin" if unset. +ADMIN_PASSWORD=change-me-to-a-long-random-string + +# Secret used to sign the admin session cookie (HMAC-SHA256). +# Set to a long random value; if unset it derives from ADMIN_PASSWORD. +ADMIN_SESSION_SECRET=another-long-random-string + +# Where the SQLite content DB and uploaded images live. On the Docker +# deployment this is a mounted volume so content survives rebuilds. +# Defaults to ./data when unset. +DATA_DIR=./data diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..449fa6c --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,35 @@ +name: ci + +on: + pull_request: + push: + branches-ignore: + - main + +jobs: + build: + runs-on: self-hosted + + env: + DOCKER_BUILDKIT: 1 + + steps: + + - name: Checkout + env: + TOKEN: ${{ github.token }} + REF: ${{ github.ref }} + run: | + git init + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git config http.extraheader "Authorization: Bearer ${TOKEN}" + git fetch --depth=1 origin "${REF}" + git checkout FETCH_HEAD + + - name: Docker Build Test + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + docker build \ + --build-arg NPM_TOKEN="$NPM_TOKEN" \ + -t soroushasadi-site:test . \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..17b13e6 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,73 @@ +name: deploy + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: self-hosted + + env: + DOCKER_BUILDKIT: 1 + COMPOSE_DOCKER_CLI_BUILD: 1 + + steps: + + - name: Checkout + env: + TOKEN: ${{ github.token }} + REF: ${{ github.ref }} + run: | + git init + git remote remove origin 2>/dev/null || true + git remote add origin "${{ github.server_url }}/${{ github.repository }}.git" + git config http.extraheader "Authorization: Bearer ${TOKEN}" + git fetch --depth=1 origin "${REF}" + git checkout FETCH_HEAD + + - name: Create Environment File + run: | + cat > .env << EOF + ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }} + ADMIN_SESSION_SECRET=${{ secrets.ADMIN_SESSION_SECRET }} + RESEND_API_KEY=${{ secrets.RESEND_API_KEY }} + CONTACT_INBOX=${{ secrets.CONTACT_INBOX }} + CONTACT_FROM=${{ secrets.CONTACT_FROM }} + NPM_TOKEN=${{ secrets.NPM_TOKEN }} + EOF + + - name: Build Container + run: | + docker compose build + + - name: Deploy + run: | + docker compose up -d --remove-orphans + + - name: Wait For Health Check + run: | + for i in $(seq 1 30); do + + STATUS=$(docker inspect \ + --format='{{.State.Health.Status}}' \ + soroushasadi-site 2>/dev/null) + + echo "Status: $STATUS" + + if [ "$STATUS" = "healthy" ]; then + echo "Deployment successful" + exit 0 + fi + + sleep 5 + done + + docker logs soroushasadi-site --tail 100 + exit 1 + + - name: Cleanup + if: success() + run: | + docker image prune -f \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..347cc55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# dependencies +node_modules +.pnp +.pnp.js + +# next.js +.next/ +out/ +build/ + +# production +dist/ + +# typescript +*.tsbuildinfo +next-env.d.ts + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# env +.env*.local +.env + +# vercel +.vercel + +# CMS data (SQLite DB + uploaded media live in the mounted volume) +/data + +# local tooling / agent state +.claude/ + diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..44d737f --- /dev/null +++ b/.npmrc @@ -0,0 +1,13 @@ +# All npm traffic is proxied through the Nexus npm-group repository. npm rewrites +# the registry.npmjs.org hosts found in package-lock.json to this mirror at +# install time (default replace-registry-host=npmjs), so the committed lockfile +# is reused as-is — no regeneration needed. +registry=https://mirror.soroushasadi.com/repository/npm-group/ + +# Auth is never committed. CI and the Docker build append an `_authToken` line +# from the NPM_TOKEN secret at install time; for local installs put the token in +# your personal ~/.npmrc. See .gitea/workflows/*.yml. + +# Trim install noise and avoid extra round-trips to the public registry. +audit=false +fund=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8798784 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# All base images flow through the Nexus docker-group proxy, which aggregates +# Docker Hub, Microsoft Container Registry (mcr.microsoft.com) and GitHub +# Container Registry (ghcr.io) behind one path — any upstream image is reachable +# as mirror.soroushasadi.com/repository/docker-group/. +# Build directly against Docker Hub instead with: +# --build-arg NODE_IMAGE=node:20-slim +ARG NODE_IMAGE=node:20-slim +# --------------------------------------------------------------------------- +# 1. Dependencies — installs node_modules and compiles the better-sqlite3 +# native addon (needs python3 + a C++ toolchain). +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS deps +WORKDIR /app +RUN apt-get update \ + && apt-get install -y --no-install-recommends python3 make g++ ca-certificates \ + && rm -rf /var/lib/apt/lists/* +# .npmrc points npm at the Nexus npm-group; NPM_TOKEN (optional) authenticates. +# The token is written only into this build stage and never reaches the runner +# image, which copies node_modules — not .npmrc. +COPY package.json package-lock.json ./ +RUN if [ -n "$NPM_TOKEN" ]; then \ + echo "//mirror.soroushasadi.com/repository/npm-group/:_authToken=${NPM_TOKEN}" >> .npmrc ; \ + fi \ + && npm ci + +# --------------------------------------------------------------------------- +# 2. Builder — produces the standalone Next.js server bundle. +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# --------------------------------------------------------------------------- +# 3. Runner — minimal runtime image. Content DB + uploads live in /data, +# which is a mounted volume so they survive image rebuilds. +# --------------------------------------------------------------------------- +FROM ${NODE_IMAGE} AS runner +WORKDIR /app +ENV NODE_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + PORT=3000 \ + HOSTNAME=0.0.0.0 \ + DATA_DIR=/data + +RUN groupadd -g 1001 nodejs && useradd -u 1001 -g nodejs -m nextjs + +# Standalone server, static assets, and the public/ tree (portfolio art etc.). +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/public ./public + +# Native module + its loaders. Next's file tracing usually copies these, but +# we copy the compiled .node and bindings explicitly as a safety net. +COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3 +COPY --from=builder /app/node_modules/bindings ./node_modules/bindings +COPY --from=builder /app/node_modules/file-uri-to-path ./node_modules/file-uri-to-path + +RUN mkdir -p /data/uploads && chown -R nextjs:nodejs /data /app +USER nextjs + +VOLUME ["/data"] +EXPOSE 3000 + +CMD ["node", "server.js"] diff --git a/app/(admin)/admin/login/page.tsx b/app/(admin)/admin/login/page.tsx new file mode 100644 index 0000000..992b7f2 --- /dev/null +++ b/app/(admin)/admin/login/page.tsx @@ -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(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 ( +
+
+
+
+ + SA + +
+

Content CMS

+

+ soroushasadi.ir +

+
+
+ + + 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 &&

{error}

} + + +
+
+ ); +} + +export default function AdminLoginPage() { + return ( + + + + ); +} diff --git a/app/(admin)/admin/page.tsx b/app/(admin)/admin/page.tsx new file mode 100644 index 0000000..08fd86d --- /dev/null +++ b/app/(admin)/admin/page.tsx @@ -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 ( + +
+
+

Dashboard

+

+ Edit every section of the site. {edited > 0 ? `${edited} section${edited > 1 ? 's' : ''} customized.` : 'All sections are at their defaults.'} +

+
+ + {usingDefaultPassword && ( +
+ Heads up: no ADMIN_PASSWORD is set, so the dev default + (admin) is in use. Set one in your environment before going live. +
+ )} + +
+ {EDITABLE_SECTIONS.map((s) => { + const edited = status[s.key]; + return ( + +
+

+ {s.label.en} + + {s.label.fa} + +

+ {edited ? ( + + edited · {timeAgo(edited)} + + ) : ( + + default + + )} +
+

{s.desc.en}

+ + ); + })} +
+ +
+ +
+

+ Journal articles + مقالات +

+ + bodies → + +
+

+ Edit the full bilingual body of each blog post (lead + content blocks). +

+ +
+
+
+ ); +} diff --git a/app/(admin)/admin/posts/[slug]/page.tsx b/app/(admin)/admin/posts/[slug]/page.tsx new file mode 100644 index 0000000..4260487 --- /dev/null +++ b/app/(admin)/admin/posts/[slug]/page.tsx @@ -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 ( + +
+ + ← Journal articles + +

{title}

+

+ Edit the lead and body blocks for both languages, then save. Changes go live immediately. +

+ + +
+
+ ); +} diff --git a/app/(admin)/admin/posts/page.tsx b/app/(admin)/admin/posts/page.tsx new file mode 100644 index 0000000..bbbd7da --- /dev/null +++ b/app/(admin)/admin/posts/page.tsx @@ -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( + en.blog.items.map((p) => [p.slug, p]), + ); + const slugs = Object.keys(posts); + const editedCount = Object.keys(overrides).length; + + return ( + +
+
+

Journal articles

+

+ 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{' '} + + Journal + {' '} + section. +

+
+ +
+ {slugs.map((slug) => { + const card = cardBySlug.get(slug); + const post = posts[slug]; + const edited = slug in overrides; + return ( + +
+

+ {card?.title ?? slug} +

+ {edited ? ( + + edited + + ) : ( + + default + + )} +
+
+ {card?.category ?? '—'} + · + {post.date} +
+ + ); + })} +
+
+
+ ); +} diff --git a/app/(admin)/admin/sections/[key]/page.tsx b/app/(admin)/admin/sections/[key]/page.tsx new file mode 100644 index 0000000..6c85cb4 --- /dev/null +++ b/app/(admin)/admin/sections/[key]/page.tsx @@ -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 ( + +
+ + ← Dashboard + +

+ {label.en} + + {label.fa} + +

+

+ Edit both languages with the FA / EN tabs, then save. Changes go live immediately. +

+ + +
+
+ ); +} diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx new file mode 100644 index 0000000..389b45c --- /dev/null +++ b/app/(admin)/layout.tsx @@ -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
{children}
; +} diff --git a/app/(site)/blog/[slug]/page.tsx b/app/(site)/blog/[slug]/page.tsx new file mode 100644 index 0000000..a9f4b26 --- /dev/null +++ b/app/(site)/blog/[slug]/page.tsx @@ -0,0 +1,46 @@ +import { notFound } from 'next/navigation'; +import { loadContent } from '@/lib/content/load'; +import { loadPost } from '@/lib/content/posts-store'; +import { BlogArticle } from '@/components/blog/BlogArticle'; + +// Live content: bodies and card meta both come from the CMS-merged tree, so +// admin edits show immediately. (No generateStaticParams — render on demand.) +export const dynamic = 'force-dynamic'; + +type Params = { slug: string }; + +export function generateMetadata({ params }: { params: Params }) { + const { en } = loadContent(); + const post = en.blog.items.find((p) => p.slug === params.slug); + if (!post) return {}; + return { + title: post.title, + description: post.excerpt, + openGraph: { title: post.title, description: post.excerpt, type: 'article' }, + alternates: { + canonical: `/blog/${post.slug}`, + languages: { + 'fa-IR': `/blog/${post.slug}`, + 'en-US': `/blog/${post.slug}`, + }, + }, + }; +} + +export default function BlogPostPage({ params }: { params: Params }) { + const content = loadPost(params.slug); + const { en: enContent, fa: faContent } = loadContent(); + const en = enContent.blog.items.find((p) => p.slug === params.slug); + const fa = faContent.blog.items.find((p) => p.slug === params.slug); + if (!content || !en || !fa) notFound(); + + return ( + + ); +} diff --git a/app/(site)/layout.tsx b/app/(site)/layout.tsx new file mode 100644 index 0000000..c4097cb --- /dev/null +++ b/app/(site)/layout.tsx @@ -0,0 +1,38 @@ +import { LocaleProvider } from '@/lib/i18n/locale-context'; +import { loadContent } from '@/lib/content/load'; +import { Navbar } from '@/components/nav/Navbar'; +import { CustomCursor } from '@/components/ui/CustomCursor'; + +/** + * Public site shell. Reads the live content tree (dict defaults merged with + * any admin overrides) on every request so edits made in the panel appear + * immediately, then feeds it to the client-side LocaleProvider. + */ +export const dynamic = 'force-dynamic'; + +export default function SiteLayout({ children }: { children: React.ReactNode }) { + const content = loadContent(); + + return ( + + {/* Ambient backdrop */} +
+
+ + +
{children}
+ + ); +} diff --git a/app/(site)/page.tsx b/app/(site)/page.tsx new file mode 100644 index 0000000..bbf5266 --- /dev/null +++ b/app/(site)/page.tsx @@ -0,0 +1,25 @@ +import { Hero } from '@/components/hero/Hero'; +import { Services } from '@/components/sections/Services'; +import { DataFlow } from '@/components/sections/DataFlow'; +import { Stack } from '@/components/sections/Stack'; +import { Expertise } from '@/components/sections/Expertise'; +import { Portfolio } from '@/components/sections/Portfolio'; +import { Blog } from '@/components/sections/Blog'; +import { Contact } from '@/components/sections/Contact'; +import { Footer } from '@/components/sections/Footer'; + +export default function HomePage() { + return ( + <> + + + + + + + + +