From 29b70c6865850e31f31bb67a0b43b1903d81d96f Mon Sep 17 00:00:00 2001 From: Ali Taghavi Date: Sun, 3 May 2026 14:07:58 +0330 Subject: [PATCH] feat: add admin API routes for tags, timeline events, uploads, and uses management - Implemented GET and POST endpoints for managing tags in `src/app/api/admin/tags/route.ts`. - Created PUT and DELETE endpoints for timeline events in `src/app/api/admin/timeline/[id]/route.ts`. - Added GET and POST endpoints for timeline management in `src/app/api/admin/timeline/route.ts`. - Developed file upload functionality with validation in `src/app/api/admin/upload/route.ts`. - Introduced PUT and DELETE endpoints for managing uses items in `src/app/api/admin/uses/[id]/route.ts`. - Added GET and POST endpoints for uses management in `src/app/api/admin/uses/route.ts`. feat: enhance admin UI components for better user experience - Created `AdminSidebar` component for navigation in `src/components/admin/AdminSidebar.tsx`. - Developed `ImageUpload` component for handling image uploads in `src/components/admin/ImageUpload.tsx`. - Implemented `RichTextEditor` component for rich text editing in `src/components/admin/RichTextEditor.tsx`. - Added `TagsInput` component for managing tags in `src/components/admin/TagsInput.tsx`. - Created `TiptapRenderer` component for rendering HTML content in `src/components/writing/TiptapRenderer.tsx`. feat: establish database interaction layer with Prisma - Added database connection and session management in `src/lib/db.ts` and `src/lib/auth.ts`. - Implemented CRUD operations for posts, products, projects, settings, timeline events, and uses items in respective files under `src/lib/db/`. - Introduced utility functions for formatting dates and slug generation in `src/lib/types.ts`. --- DEPLOY.md | 259 +++++++++++ Dockerfile | 52 ++- docker-compose.yml | 51 ++- next.config.ts | 7 +- nodecloud-server-context.md | 433 ++++++++++++++++++ package.json | 28 +- prisma/schema.prisma | 153 +++++++ sanity/sanity.config.ts | 36 -- sanity/schemas/author.ts | 25 - sanity/schemas/category.ts | 14 - sanity/schemas/index.ts | 9 - sanity/schemas/post.ts | 56 --- sanity/schemas/product.ts | 29 -- sanity/schemas/project.ts | 51 --- sanity/schemas/siteSettings.ts | 27 -- sanity/schemas/tag.ts | 12 - sanity/schemas/timelineEvent.ts | 21 - sanity/schemas/usesItem.ts | 29 -- src/app/[locale]/about/page.tsx | 53 ++- src/app/[locale]/page.tsx | 4 +- src/app/[locale]/shop/page.tsx | 169 +++---- src/app/[locale]/uses/page.tsx | 34 +- src/app/[locale]/work/[slug]/page.tsx | 53 +-- src/app/[locale]/work/page.tsx | 11 +- src/app/[locale]/writing/[slug]/page.tsx | 78 ++-- src/app/[locale]/writing/page.tsx | 20 +- src/app/admin/layout.tsx | 44 ++ src/app/admin/login/page.tsx | 100 ++++ src/app/admin/messages/page.tsx | 124 +++++ src/app/admin/page.tsx | 132 ++++++ src/app/admin/posts/PostForm.tsx | 262 +++++++++++ src/app/admin/posts/[id]/page.tsx | 47 ++ src/app/admin/posts/new/page.tsx | 18 + src/app/admin/posts/page.tsx | 129 ++++++ src/app/admin/products/ProductForm.tsx | 128 ++++++ src/app/admin/products/[id]/page.tsx | 32 ++ src/app/admin/products/new/page.tsx | 17 + src/app/admin/products/page.tsx | 81 ++++ src/app/admin/projects/ProjectForm.tsx | 199 ++++++++ src/app/admin/projects/[id]/page.tsx | 39 ++ src/app/admin/projects/new/page.tsx | 17 + src/app/admin/projects/page.tsx | 96 ++++ src/app/admin/settings/page.tsx | 113 +++++ src/app/admin/timeline/page.tsx | 143 ++++++ src/app/admin/uses/page.tsx | 143 ++++++ src/app/api/admin/auth/route.ts | 40 ++ src/app/api/admin/messages/[id]/route.ts | 33 ++ src/app/api/admin/messages/route.ts | 14 + src/app/api/admin/posts/[id]/route.ts | 57 +++ src/app/api/admin/posts/route.ts | 40 ++ src/app/api/admin/products/[id]/route.ts | 42 ++ src/app/api/admin/products/route.ts | 29 ++ src/app/api/admin/projects/[id]/route.ts | 42 ++ src/app/api/admin/projects/route.ts | 29 ++ src/app/api/admin/settings/route.ts | 30 ++ src/app/api/admin/setup/route.ts | 45 ++ src/app/api/admin/tags/route.ts | 29 ++ src/app/api/admin/timeline/[id]/route.ts | 30 ++ src/app/api/admin/timeline/route.ts | 27 ++ src/app/api/admin/upload/route.ts | 44 ++ src/app/api/admin/uses/[id]/route.ts | 30 ++ src/app/api/admin/uses/route.ts | 27 ++ src/app/api/contact/route.ts | 37 +- src/app/api/revalidate/route.ts | 3 +- src/app/api/rss/route.ts | 16 +- src/app/globals.css | 45 ++ src/app/studio/[[...tool]]/page.tsx | 10 - src/components/admin/AdminSidebar.tsx | 137 ++++++ src/components/admin/ImageUpload.tsx | 105 +++++ src/components/admin/RichTextEditor.tsx | 184 ++++++++ src/components/admin/TagsInput.tsx | 112 +++++ src/components/home/LatestWriting.tsx | 102 ++--- src/components/work/ProjectCard.tsx | 19 +- src/components/work/WorkListClient.tsx | 6 +- src/components/writing/CategoryFilter.tsx | 2 +- .../writing/PortableTextRenderer.tsx | 106 ----- src/components/writing/PostCard.tsx | 24 +- src/components/writing/TiptapRenderer.tsx | 21 + src/components/writing/WritingListClient.tsx | 18 +- src/lib/auth.ts | 32 ++ src/lib/db.ts | 11 + src/lib/db/posts.ts | 72 +++ src/lib/db/products.ts | 13 + src/lib/db/projects.ts | 47 ++ src/lib/db/settings.ts | 14 + src/lib/db/timeline.ts | 8 + src/lib/db/uses.ts | 8 + src/lib/sanity/client.ts | 18 - src/lib/sanity/image.ts | 9 - src/lib/sanity/queries.ts | 115 ----- src/lib/sanity/types.ts | 63 --- src/lib/sanity/utils.ts | 34 -- src/lib/types.ts | 84 ++++ 93 files changed, 4561 insertions(+), 1080 deletions(-) create mode 100644 DEPLOY.md create mode 100644 nodecloud-server-context.md create mode 100644 prisma/schema.prisma delete mode 100644 sanity/sanity.config.ts delete mode 100644 sanity/schemas/author.ts delete mode 100644 sanity/schemas/category.ts delete mode 100644 sanity/schemas/index.ts delete mode 100644 sanity/schemas/post.ts delete mode 100644 sanity/schemas/product.ts delete mode 100644 sanity/schemas/project.ts delete mode 100644 sanity/schemas/siteSettings.ts delete mode 100644 sanity/schemas/tag.ts delete mode 100644 sanity/schemas/timelineEvent.ts delete mode 100644 sanity/schemas/usesItem.ts create mode 100644 src/app/admin/layout.tsx create mode 100644 src/app/admin/login/page.tsx create mode 100644 src/app/admin/messages/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/admin/posts/PostForm.tsx create mode 100644 src/app/admin/posts/[id]/page.tsx create mode 100644 src/app/admin/posts/new/page.tsx create mode 100644 src/app/admin/posts/page.tsx create mode 100644 src/app/admin/products/ProductForm.tsx create mode 100644 src/app/admin/products/[id]/page.tsx create mode 100644 src/app/admin/products/new/page.tsx create mode 100644 src/app/admin/products/page.tsx create mode 100644 src/app/admin/projects/ProjectForm.tsx create mode 100644 src/app/admin/projects/[id]/page.tsx create mode 100644 src/app/admin/projects/new/page.tsx create mode 100644 src/app/admin/projects/page.tsx create mode 100644 src/app/admin/settings/page.tsx create mode 100644 src/app/admin/timeline/page.tsx create mode 100644 src/app/admin/uses/page.tsx create mode 100644 src/app/api/admin/auth/route.ts create mode 100644 src/app/api/admin/messages/[id]/route.ts create mode 100644 src/app/api/admin/messages/route.ts create mode 100644 src/app/api/admin/posts/[id]/route.ts create mode 100644 src/app/api/admin/posts/route.ts create mode 100644 src/app/api/admin/products/[id]/route.ts create mode 100644 src/app/api/admin/products/route.ts create mode 100644 src/app/api/admin/projects/[id]/route.ts create mode 100644 src/app/api/admin/projects/route.ts create mode 100644 src/app/api/admin/settings/route.ts create mode 100644 src/app/api/admin/setup/route.ts create mode 100644 src/app/api/admin/tags/route.ts create mode 100644 src/app/api/admin/timeline/[id]/route.ts create mode 100644 src/app/api/admin/timeline/route.ts create mode 100644 src/app/api/admin/upload/route.ts create mode 100644 src/app/api/admin/uses/[id]/route.ts create mode 100644 src/app/api/admin/uses/route.ts delete mode 100644 src/app/studio/[[...tool]]/page.tsx create mode 100644 src/components/admin/AdminSidebar.tsx create mode 100644 src/components/admin/ImageUpload.tsx create mode 100644 src/components/admin/RichTextEditor.tsx create mode 100644 src/components/admin/TagsInput.tsx delete mode 100644 src/components/writing/PortableTextRenderer.tsx create mode 100644 src/components/writing/TiptapRenderer.tsx create mode 100644 src/lib/auth.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/db/posts.ts create mode 100644 src/lib/db/products.ts create mode 100644 src/lib/db/projects.ts create mode 100644 src/lib/db/settings.ts create mode 100644 src/lib/db/timeline.ts create mode 100644 src/lib/db/uses.ts delete mode 100644 src/lib/sanity/client.ts delete mode 100644 src/lib/sanity/image.ts delete mode 100644 src/lib/sanity/queries.ts delete mode 100644 src/lib/sanity/types.ts delete mode 100644 src/lib/sanity/utils.ts create mode 100644 src/lib/types.ts diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 00000000..3ab029cf --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,259 @@ +# biztaghavi.com — Deployment Guide + +> Server: `193.105.234.35` (NODE-Cloud, Iran) +> Stack: Next.js 16 · MariaDB 11 · Prisma · pnpm · Docker · Nginx +> Domain: `biztaghavi.com` (behind ArvanCloud CDN) + +--- + +## 0. Pre-flight — do this ONCE on your Mac + +### Download Prisma engine binaries + +Prisma cannot download its binaries on the server (`binaries.prisma.sh` is blocked). +Run this on your Mac: + +```bash +# Find your exact Prisma version first +cat package.json | grep '"prisma"' +# e.g. "6.x.x" + +# Download for Alpine Linux (linux-musl-openssl-3.0.x) +npx prisma@ fetch-engines --version linux-musl-openssl-3.0.x + +# The binaries land in ~/.prisma/engines/ — copy them to the repo +mkdir -p prisma-binaries +cp ~/.prisma/engines/libquery_engine-linux-musl-openssl-3.0.x.so.node prisma-binaries/ +cp ~/.prisma/engines/schema-engine-linux-musl-openssl-3.0.x prisma-binaries/ + +# Commit them +git add prisma-binaries/ +git commit -m "add prisma engine binaries for linux-musl" +``` + +> These files are ~50 MB. They must be in git before deployment. + +--- + +## 1. First-time server setup + +```bash +ssh root@193.105.234.35 + +# Create app directory +mkdir -p /srv/nodecloud/apps/biztaghavi +cd /srv/nodecloud/apps/biztaghavi + +# Clone from Gitea +git clone http://git.nodecloud.ir/nodegroup/biztaghavi.git . + +# Create environment file +cp .env.example .env +nano .env # fill in real values — see section 2 +``` + +--- + +## 2. Environment variables (`.env`) + +```env +DATABASE_URL=mysql://biztaghavi:STRONG_PASSWORD@db:3306/biztaghavi +SESSION_SECRET=<64-char random hex — run: openssl rand -hex 32> +SETUP_KEY= +RESEND_API_KEY= +CONTACT_EMAIL=ali@biztaghavi.com +NEXT_PUBLIC_SITE_URL=https://biztaghavi.com + +# Docker DB credentials (must match DATABASE_URL above) +DB_ROOT_PASSWORD= +DB_NAME=biztaghavi +DB_USER=biztaghavi +DB_PASSWORD= +``` + +--- + +## 3. Build & launch + +```bash +cd /srv/nodecloud/apps/biztaghavi + +# Build (first time is slow — ~5-10 min due to Liara mirror rate limiting) +docker compose build --no-cache + +# Launch +docker compose up -d + +# Verify containers are running +docker compose ps + +# Check logs +docker compose logs -f next-app +``` + +--- + +## 4. Run database migrations + +Prisma migrations run via direct SQL (not `prisma migrate` — that would need network). + +```bash +# Generate migration SQL from schema +# (run this on your Mac, then copy the SQL to the server) +npx prisma migrate diff \ + --from-empty \ + --to-schema-datamodel prisma/schema.prisma \ + --script > migration.sql + +# Copy SQL to server +scp migration.sql root@193.105.234.35:/tmp/ + +# Apply on server +docker exec -i biztaghavi-db-1 mariadb \ + -u biztaghavi -p'STRONG_PASSWORD' biztaghavi < /tmp/migration.sql +``` + +Or use `prisma db push` directly on the container (doesn't require migrations directory): + +```bash +docker exec -it biztaghavi-next-app-1 sh -c \ + "DATABASE_URL=mysql://biztaghavi:STRONG_PASSWORD@db:3306/biztaghavi \ + npx prisma db push --skip-generate" +``` + +--- + +## 5. Create admin user (one-time) + +After containers are running: + +```bash +curl -X POST https://biztaghavi.com/api/admin/setup \ + -H "Content-Type: application/json" \ + -H "x-setup-key: YOUR_SETUP_KEY" \ + -d '{"username":"ali","password":"YOUR_STRONG_PASSWORD"}' +``` + +Then **remove `SETUP_KEY` from `.env`** and restart the app: + +```bash +# Edit .env — delete the SETUP_KEY line +docker compose restart next-app +``` + +Admin panel: `https://biztaghavi.com/admin` + +--- + +## 6. Nginx config + +Port: **3009** (next available after 3008 for Khanehban) + +Create `/etc/nginx/sites-available/biztaghavi.com`: + +```nginx +server { + listen 80; + listen [::]:80; + listen 443 ssl; + listen [::]:443 ssl; + server_name biztaghavi.com www.biztaghavi.com; + + ssl_certificate /etc/ssl/certs/nodecloud-selfsigned.crt; + ssl_certificate_key /etc/ssl/private/nodecloud-selfsigned.key; + + client_max_body_size 20M; + + location / { + proxy_pass http://127.0.0.1:3009; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60; + proxy_send_timeout 60; + } +} +``` + +```bash +ln -s /etc/nginx/sites-available/biztaghavi.com /etc/nginx/sites-enabled/ +nginx -t && nginx -s reload +``` + +In **ArvanCloud dashboard**: SSL mode = "Full", origin = `193.105.234.35`, purge cache. + +> Port 3009 must match the `ports` binding in `docker-compose.yml` — verify it's `"127.0.0.1:3009:3000"`. + +--- + +## 7. Uploaded images volume + +Images uploaded via the admin panel land in `/app/public/uploads/` inside the container. +The `uploads` Docker volume keeps them across redeployments. + +To back up uploads manually: +```bash +docker cp biztaghavi-next-app-1:/app/public/uploads ./uploads-backup/ +``` + +--- + +## 8. Redeployment (after code changes) + +```bash +cd /srv/nodecloud/apps/biztaghavi +git pull + +# Re-apply Iran Dockerfile is already correct — no extra steps needed +docker compose build --no-cache +docker compose up -d +``` + +> If Prisma schema changed, re-run the migration SQL step (section 4). + +--- + +## 9. Troubleshooting + +| Problem | Fix | +|---|---| +| Build hangs / 429 from Liara mirror | Wait 15 min, retry. Or transfer `node_modules` from Mac. | +| Prisma "engine not found" | Check `prisma-binaries/` has both files and is committed. | +| Container starts but site 502 | `docker compose logs -f next-app` — likely DB connection timeout. | +| Images not showing | Make sure `uploads` volume is mounted and `client_max_body_size 20M` is in nginx. | +| Admin login fails | Double-check `SESSION_SECRET` is 32+ chars and consistent across restarts. | +| DB won't start | MariaDB 11 healthcheck takes ~30s on first boot — wait and retry. | + +--- + +## 10. Transferring `node_modules` from Mac (fallback if Liara mirror fails) + +```bash +# On Mac — install for Linux Alpine +npm install \ + --platform=linux --arch=x64 --libc=musl \ + --ignore-scripts + +# Also install musl-specific native binaries +npm install \ + @next/swc-linux-x64-musl \ + lightningcss-linux-x64-musl \ + @tailwindcss/oxide-linux-x64-musl \ + --platform=linux --arch=x64 --libc=musl + +# Tar it +tar -czf node_modules.tar.gz node_modules/ + +# Upload to server +scp node_modules.tar.gz root@193.105.234.35:/srv/nodecloud/apps/biztaghavi/ + +# On server — extract and build without install +tar -xzf node_modules.tar.gz +docker compose build --no-cache # Dockerfile will skip pnpm install if node_modules exists +``` + +> You may need to modify the Dockerfile's `RUN pnpm install` step to skip if `node_modules/` already exists when using this approach. diff --git a/Dockerfile b/Dockerfile index 97e5c75d..560b3147 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,57 @@ -FROM node:22-alpine AS base - -# Install pnpm -RUN corepack enable && corepack prepare pnpm@latest --activate - +FROM docker.arvancloud.ir/library/node:20-alpine AS base +ENV NEXT_TELEMETRY_DISABLED=1 +ENV COREPACK_NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ +ENV NEXT_SWC_DOWNLOAD_DISABLED=1 +ENV PRISMA_SKIP_POSTINSTALL_GENERATE=1 +ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 WORKDIR /app -# Dependencies stage -FROM base AS deps -COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./ -RUN pnpm install --frozen-lockfile +RUN corepack enable && corepack prepare pnpm@9 --activate -# Build stage +# ── deps ────────────────────────────────────────────────────────────────────── +FROM base AS deps +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm config set registry https://package-mirror.liara.ir/repository/npm/ && \ + pnpm install --frozen-lockfile --network-concurrency 1 + +# ── builder ─────────────────────────────────────────────────────────────────── FROM base AS builder COPY --from=deps /app/node_modules ./node_modules COPY . . -ENV NEXT_TELEMETRY_DISABLED=1 +# Copy pre-downloaded Prisma engine binaries (linux-musl-openssl-3.0.x) +COPY prisma-binaries/ ./prisma-binaries/ +ENV PRISMA_QUERY_ENGINE_LIBRARY=/app/prisma-binaries/libquery_engine-linux-musl-openssl-3.0.x.so.node +ENV PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma-binaries/schema-engine-linux-musl-openssl-3.0.x + +RUN pnpm prisma generate RUN pnpm build -# Runner stage -FROM node:22-alpine AS runner +# ── runner ──────────────────────────────────────────────────────────────────── +FROM docker.arvancloud.ir/library/node:20-alpine AS runner WORKDIR /app - ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" ENV NEXT_TELEMETRY_DISABLED=1 +ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs +# Prisma client needs the query engine at runtime +COPY --from=builder /app/prisma-binaries ./prisma-binaries +COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/@prisma/client ./node_modules/@prisma/client + +ENV PRISMA_QUERY_ENGINE_LIBRARY=/app/prisma-binaries/libquery_engine-linux-musl-openssl-3.0.x.so.node + COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +RUN mkdir -p /app/public/uploads && chown nextjs:nodejs /app/public/uploads + USER nextjs - EXPOSE 3000 -ENV PORT=3000 -ENV HOSTNAME="0.0.0.0" - CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 9015ffaf..1bfa733d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,41 +1,48 @@ version: "3.9" services: + db: + image: docker.arvancloud.ir/library/mariadb:11 + container_name: biztaghavi_db + restart: unless-stopped + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME:-biztaghavi} + MARIADB_USER: ${DB_USER:-biztaghavi} + MARIADB_PASSWORD: ${DB_PASSWORD} + volumes: + - db-data:/var/lib/mysql + networks: + - app-network + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 20 + next-app: build: context: . dockerfile: Dockerfile + container_name: biztaghavi_app restart: unless-stopped + depends_on: + db: + condition: service_healthy + env_file: .env environment: NODE_ENV: production - NEXT_PUBLIC_SANITY_PROJECT_ID: ${SANITY_PROJECT_ID} - NEXT_PUBLIC_SANITY_DATASET: ${SANITY_DATASET:-production} - SANITY_API_TOKEN: ${SANITY_API_TOKEN} - RESEND_API_KEY: ${RESEND_API_KEY} - NEXT_PUBLIC_UMAMI_WEBSITE_ID: ${UMAMI_WEBSITE_ID} - NEXT_PUBLIC_UMAMI_URL: ${UMAMI_URL} - expose: - - "3000" - networks: - - app-network - - nginx: - image: nginx:alpine - restart: unless-stopped + DATABASE_URL: mysql://${DB_USER:-biztaghavi}:${DB_PASSWORD}@db:3306/${DB_NAME:-biztaghavi} ports: - - "80:80" - - "443:443" + - "127.0.0.1:3009:3000" volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - - /etc/letsencrypt:/etc/letsencrypt:ro - - certbot-webroot:/var/www/certbot - depends_on: - - next-app + - uploads:/app/public/uploads networks: - app-network volumes: - certbot-webroot: + db-data: + uploads: networks: app-network: diff --git a/next.config.ts b/next.config.ts index 42d9b042..785eb1d6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -7,14 +7,15 @@ const nextConfig: NextConfig = { output: "standalone", images: { remotePatterns: [ - { protocol: "https", hostname: "cdn.sanity.io" }, + { protocol: "https", hostname: "biztaghavi.com" }, ], + localPatterns: [{ pathname: "/uploads/**" }], formats: ["image/avif", "image/webp"], deviceSizes: [640, 750, 828, 1080, 1200, 1920], - minimumCacheTTL: 60 * 60 * 24 * 30, // 30 days + minimumCacheTTL: 60 * 60 * 24 * 30, }, experimental: { - optimizePackageImports: ["lucide-react", "framer-motion", "@sanity/image-url"], + optimizePackageImports: ["lucide-react", "framer-motion"], }, compress: true, poweredByHeader: false, diff --git a/nodecloud-server-context.md b/nodecloud-server-context.md new file mode 100644 index 00000000..64061e37 --- /dev/null +++ b/nodecloud-server-context.md @@ -0,0 +1,433 @@ +# NODE-Cloud Server — Master Context for AI Assistants + +> Last updated: 2026-04-27 +> Owner: Ali Taghavi (@biztaghavi), CEO of NODE-Group (Novin Ofogh Dadeh Etemad) +> Purpose: Paste this into any new Claude/AI chat when working on this server or deploying new apps. + +--- + +## 1. Server Overview + +| Field | Value | +|---|---| +| Hostname | `srv9588446167` | +| IP | `193.105.234.35` | +| OS | Ubuntu 24.04.4 LTS | +| Kernel | 6.8.0-107-generic | +| CPU | 4 cores (KVM/QEMU) | +| RAM | 3.8 GB | +| Disk | 145 GB (29 GB used, 116 GB free) | +| Swap | None | +| Virtualization | KVM | +| SSH | Port 22, root access | +| Docker | 28.2.2 with Compose 2.37.1 | +| Nginx | 1.24.0 | +| Certbot | 2.9.0 with auto-renewal timer active | + +--- + +## 2. Iran-Specific Constraints (CRITICAL — Read Before Any Deployment) + +This server is hosted in Iran. Several international services are blocked or unreliable: + +### 2.1 Docker Images +- **Docker Hub is blocked.** All images must use the ArvanCloud mirror: `docker.arvancloud.ir/library/` +- Examples: `docker.arvancloud.ir/library/node:20-alpine`, `docker.arvancloud.ir/library/mariadb:11`, `docker.arvancloud.ir/library/redis:7-alpine` +- Docker is configured with mirror: `https://docker-mirror.liara.ir/` + +### 2.2 npm/Node.js Packages +- **npmjs.org is intermittently blocked.** Use Liara's npm mirror: `https://package-mirror.liara.ir/repository/npm/` +- **npm v10 has a known bug** ("Exit handler never called") — it silently fails and doesn't install packages. Use **pnpm** instead. +- **pnpm installation:** Use `corepack enable && corepack prepare pnpm@9 --activate` with `ENV COREPACK_NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/` +- **Liara mirror rate-limits aggressively** — use `--network-concurrency 1` to avoid 429 errors. If rate-limited, wait 10-30 minutes and retry. +- **Fallback strategy for large projects:** Install `node_modules` on a Mac/dev machine with unrestricted internet, then `scp` the tarball to the server. When doing this: + - Install with `npm install --platform=linux --arch=x64 --libc=musl` to get Linux Alpine binaries + - Also manually install native musl binaries: `@next/swc-linux-x64-musl`, `lightningcss-linux-x64-musl`, `@tailwindcss/oxide-linux-x64-musl` + - Match SWC version to the exact Next.js version (check `package.json`) + - Set `ENV NEXT_SWC_DOWNLOAD_DISABLED=1` in Dockerfile to prevent Next.js from trying to download SWC + +### 2.3 Alpine Linux Packages +- **`apk add` is blocked** — `dl-cdn.alpinelinux.org` is unreachable. Never use `apk add` in Dockerfiles. The base `node:20-alpine` image already has what's needed. + +### 2.4 Google Services +- **Google Fonts are blocked at build time.** If a project uses `next/font/google`, it must be replaced with `next/font/local` or `@fontsource/*` local packages. +- `fonts.googleapis.com` is unreachable during Docker builds. + +### 2.5 Prisma ORM +- **`binaries.prisma.sh` is blocked.** Prisma cannot download its engine binaries during build. +- **Solution:** Pre-download Prisma engine binaries on a machine with internet access, commit them to the repo in `prisma-binaries/`, and set these env vars in the Dockerfile: + ```dockerfile + ENV PRISMA_QUERY_ENGINE_LIBRARY=/app/prisma-binaries/libquery_engine-linux-musl-openssl-3.0.x.so.node + ENV PRISMA_SCHEMA_ENGINE_BINARY=/app/prisma-binaries/schema-engine-linux-musl-openssl-3.0.x + ENV PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING=1 + ENV PRISMA_SKIP_POSTINSTALL_GENERATE=1 + ``` +- Both `libquery_engine` and `schema-engine` binaries are needed for `linux-musl-openssl-3.0.x` (Alpine). +- Run `prisma generate` in the Dockerfile after copying binaries — it uses local files, no network needed. +- For migrations, apply SQL files directly: `docker exec -i mariadb -u user -ppassword dbname < migration.sql` + +### 2.6 MySQL +- **MySQL 8.0+ Docker images crash** on this server's CPU (no x86-64-v2 support). Use **MariaDB 11** instead — fully MySQL-compatible. +- MariaDB healthcheck: `["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]` + +### 2.7 SSL / Let's Encrypt +- **Let's Encrypt cannot reach this server directly** for domains behind ArvanCloud CDN. Certbot fails with timeouts. +- **For domains behind ArvanCloud CDN:** Use ArvanCloud's built-in SSL (set mode to "Full" or "Flexible"). No certbot needed. +- **For domains pointing directly to the server IP:** Certbot works fine (e.g., pouyatebparto.ir, nodegroup.ir, hooranhr.ir). +- A **self-signed certificate** exists at `/etc/ssl/certs/nodecloud-selfsigned.crt` and `/etc/ssl/private/nodecloud-selfsigned.key` — used for domains behind ArvanCloud where ArvanCloud handles public SSL. +- **IMPORTANT:** Any new site behind ArvanCloud CDN MUST listen on port 443 with the self-signed cert in Nginx. Otherwise Nginx falls back to the first server block that has SSL (which is git.nodecloud.ir/Gitea) and serves wrong content. + +--- + +## 3. Hosted Applications + +### 3.1 Gitea (Self-hosted Git) +| Field | Value | +|---|---| +| Domain | `git.nodecloud.ir` | +| Container | `gitea` + `gitea-db` | +| Port | `127.0.0.1:3000` | +| Stack | Gitea 1.25.5 + PostgreSQL 14 | +| Compose | `/srv/nodecloud/gitea/docker-compose.yml` | +| SSL | Self-signed cert (ArvanCloud CDN handles public SSL) | + +### 3.2 Pouya Teb Parto (Medical Services) +| Field | Value | +|---|---| +| Domain | `pouyatebparto.ir` | +| Container | `pouya_teb_app` + `pouya_teb_mysql` + `pouya_teb_phpmyadmin` | +| Port | `127.0.0.1:3001` | +| Stack | Next.js 16 + MariaDB 11 + phpMyAdmin | +| Compose | `/srv/nodecloud/apps/pouya-teb-parto/docker-compose.prod.yml` | +| Git | `http://git.nodecloud.ir/pouyadolat/Pouya-teb-parto.git` | +| SSL | Let's Encrypt (certbot) | +| phpMyAdmin | `127.0.0.1:8080` (SSH tunnel only) | + +### 3.3 NODE-Group Website +| Field | Value | +|---|---| +| Domain | `nodegroup.ir` | +| Container | `nodegroup_web` | +| Port | `127.0.0.1:3002` | +| Stack | Static Next.js (exported to HTML, served by Nginx in container) | +| Compose | `/srv/nodecloud/apps/nodegroup/docker-compose.prod.yml` | +| Git | `http://git.nodecloud.ir/nodegroup/nodegroupirwebsite.git` | +| SSL | Let's Encrypt (certbot) | + +### 3.4 Hooran HR (HR Gamification Platform) +| Field | Value | +|---|---| +| Domain | `hooranhr.ir`, `www.hooranhr.ir`, `hooranhr.com`, `www.hooranhr.com` | +| Container | `hooranhr_app` + `hooranhr_mysql` | +| Port | `127.0.0.1:3003` | +| Stack | Next.js 16 + MariaDB 11 + DISC assessment | +| Compose | `/srv/nodecloud/apps/hooranhr/docker-compose.prod.yml` | +| Git | `http://git.nodecloud.ir/nodegroup/hooranhrir.git` | +| SSL | Let's Encrypt (certbot, covers all 4 domains) | + +### 3.5 Khanehban (Building Management SaaS) +| Field | Value | +|---|---| +| Domain | `khanehbaan.ir` | +| Container | `khanehbaan-app-1` + `khanehbaan-mysql-1` + `khanehbaan-redis-1` | +| Port | `127.0.0.1:3008` | +| Stack | Next.js 16 + MariaDB 11 + Redis 7 + Prisma ORM | +| Compose | `/srv/nodecloud/apps/khanehbaan/docker-compose.prod.yml` | +| Git | `http://git.nodecloud.ir/nodegroup/khanehbaan-ir.git` | +| SSL | ArvanCloud CDN (self-signed cert on server, ArvanCloud "Full" mode) | +| S3 Storage | Liara S3 (`storage.c2.liara.space`, bucket: `buildingmanagement`) — remains on Liara | +| Special | Prisma with pre-downloaded binaries, node_modules transferred from Mac | + +### 3.6 Portainer (Docker Management UI) +| Field | Value | +|---|---| +| Domain | `manage.nodecloud.ir` | +| Container | `portainer` | +| Port | `127.0.0.1:9000` | +| Stack | Portainer CE LTS | +| Compose | `/srv/nodecloud/apps/portainer/docker-compose.yml` | +| SSL | Self-signed cert (ArvanCloud CDN handles public SSL) | + +### 3.7 Nodecloud.ir (Gateway/Placeholder) +| Field | Value | +|---|---| +| Domain | `nodecloud.ir`, `www.nodecloud.ir` | +| Type | Static text response ("nodecloud main gateway is up") | +| Port | 80 only (no SSL, no proxy) | + +--- + +## 4. Port Allocation Map + +| Port | Service | Notes | +|---|---|---| +| 3000 | Gitea | Reserved | +| 3001 | Pouya Teb Parto | Next.js app | +| 3002 | NODE-Group website | Static Nginx | +| 3003 | Hooran HR | Next.js app | +| 3004-3007 | Available | — | +| 3008 | Khanehban | Next.js + Prisma | +| 3009+ | Available | — | +| 8080 | phpMyAdmin (Pouya Teb) | SSH tunnel only | +| 9000 | Portainer | Docker management UI | + +**Next available port: 3004** + +--- + +## 5. Architecture Pattern for New Deployments + +Every app follows this pattern: + +``` +Internet → ArvanCloud CDN (SSL) → Server:80/443 → Nginx → 127.0.0.1:PORT → Docker Container +``` + +### 5.1 Directory Structure +``` +/srv/nodecloud/ +├── gitea/ # Gitea (separate, not under apps/) +│ └── docker-compose.yml +└── apps/ + ├── pouya-teb-parto/ # Each app gets its own directory + │ ├── docker-compose.prod.yml + │ ├── Dockerfile + │ ├── .env + │ └── ... (source code) + ├── nodegroup/ + ├── hooranhr/ + ├── khanehbaan/ + └── portainer/ +``` + +### 5.2 Standard Dockerfile Template (Next.js + pnpm) +```dockerfile +FROM docker.arvancloud.ir/library/node:20-alpine AS builder +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PATH="/app/node_modules/.bin:$PATH" +ENV COREPACK_NPM_REGISTRY=https://package-mirror.liara.ir/repository/npm/ + +RUN corepack enable && corepack prepare pnpm@9 --activate + +COPY package.json ./ +RUN pnpm config set registry https://package-mirror.liara.ir/repository/npm/ && \ + pnpm install --no-frozen-lockfile --network-concurrency 1 + +COPY . . +RUN next build + +FROM docker.arvancloud.ir/library/node:20-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +RUN addgroup --system --gid 1001 nodejs && \ + adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +CMD ["node", "server.js"] +``` + +> **Note:** `next.config` must have `output: "standalone"` for this to work. + +### 5.3 Standard docker-compose.prod.yml Template +```yaml +services: + app: + build: . + container_name: APPNAME_app + restart: unless-stopped + ports: + - "127.0.0.1:PORT:3000" + env_file: .env + environment: + NODE_ENV: production + + # Only if app needs a database: + mysql: + image: docker.arvancloud.ir/library/mariadb:11 + container_name: APPNAME_mysql + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD} + MYSQL_DATABASE: ${MYSQL_DATABASE} + MYSQL_USER: ${MYSQL_USER} + MYSQL_PASSWORD: ${MYSQL_PASSWORD} + volumes: + - mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 20 + + # Only if app needs Redis: + redis: + image: docker.arvancloud.ir/library/redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + mysql_data: + redis_data: +``` + +### 5.4 Nginx Config Template + +**For domains with direct DNS (certbot possible):** +```nginx +server { + listen 80; + listen [::]:80; + server_name DOMAIN.ir www.DOMAIN.ir; + + client_max_body_size 20M; + + location / { + proxy_pass http://127.0.0.1:PORT; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60; + proxy_send_timeout 60; + } +} +``` +Then: `certbot --nginx -d DOMAIN.ir -d www.DOMAIN.ir` + +**For domains behind ArvanCloud CDN (MUST include SSL block):** +```nginx +server { + listen 80; + listen [::]:80; + listen 443 ssl; + listen [::]:443 ssl; + server_name DOMAIN.ir www.DOMAIN.ir; + + ssl_certificate /etc/ssl/certs/nodecloud-selfsigned.crt; + ssl_certificate_key /etc/ssl/private/nodecloud-selfsigned.key; + + client_max_body_size 20M; + + location / { + proxy_pass http://127.0.0.1:PORT; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 60; + proxy_send_timeout 60; + } +} +``` +In ArvanCloud: SSL mode = "Full", origin = `193.105.234.35`, purge cache after setup. + +--- + +## 6. Deployment Checklist for New Sites + +1. **Clone repo** to `/srv/nodecloud/apps/APPNAME/` +2. **Fix Dockerfile** for Iran: replace `node:20-alpine` with `docker.arvancloud.ir/library/node:20-alpine`, remove any `apk add`, use pnpm with Liara mirror +3. **Fix Google Fonts** if present: grep for `next/font/google` and replace +4. **Create `.env`** from `.env.example` with real credentials +5. **Use MariaDB 11** instead of MySQL 8.x +6. **Set port** in `docker-compose.prod.yml` to next available (currently 3004+) +7. **Build:** `docker compose -f docker-compose.prod.yml build --no-cache` +8. **Launch:** `docker compose -f docker-compose.prod.yml up -d` +9. **Verify:** `curl -s http://127.0.0.1:PORT | head -5` +10. **Run migrations** if needed (apply SQL directly to MariaDB container) +11. **Create Nginx config** in `/etc/nginx/sites-available/`, symlink to `sites-enabled/` +12. **SSL:** Either certbot (direct DNS) or self-signed + ArvanCloud CDN +13. **Test:** `curl -I https://DOMAIN` + +--- + +## 7. Common Operations + +### Redeploy an app after code changes +```bash +cd /srv/nodecloud/apps/APPNAME +git pull +# Re-fix Dockerfile if git pull overwrote it +docker compose -f docker-compose.prod.yml build --no-cache +docker compose -f docker-compose.prod.yml up -d +``` + +### View all container status +```bash +docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" +``` + +### View resource usage +```bash +docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" +``` + +### View logs for an app +```bash +cd /srv/nodecloud/apps/APPNAME +docker compose -f docker-compose.prod.yml logs -f app +``` + +### Restart an app +```bash +cd /srv/nodecloud/apps/APPNAME +docker compose -f docker-compose.prod.yml restart +``` + +### Access phpMyAdmin (Pouya Teb) via SSH tunnel +From your Mac: +```bash +ssh -L 8080:127.0.0.1:8080 root@193.105.234.35 +``` +Then open `http://localhost:8080` + +### Full server health check +```bash +echo "=== CONTAINERS ===" && docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}" && echo "" && echo "=== RESOURCES ===" && docker stats --no-stream --format "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}" +``` + +--- + +## 8. Firewall (UFW) + +Only three ports are open: +- **22** (SSH) +- **80** (HTTP) +- **443** (HTTPS) + +All Docker container ports are bound to `127.0.0.1` — not publicly accessible. Nginx is the only public-facing service. + +--- + +## 9. Known Issues & Gotchas + +1. **git pull overwrites Dockerfile** — After pulling, always re-apply Iran-specific fixes (image mirrors, apk removal, pnpm config). Consider committing a `Dockerfile.prod` to the repo instead. +2. **No swap configured** — With 3.8 GB RAM and ~1.8 GB used, there's ~2 GB headroom. Adding more apps may require adding swap or upgrading RAM. +3. **DNS resolver on server is slow** — `dig` commands sometimes time out. This doesn't affect app operation, only manual DNS lookups from the server. +4. **ArvanCloud CDN caching** — After any Nginx config change, always purge ArvanCloud cache and test in incognito. +5. **Self-signed cert is shared** — `git.nodecloud.ir`, `manage.nodecloud.ir`, and `khanehbaan.ir` all use the same self-signed cert. This is fine because ArvanCloud handles public SSL. +6. **Certbot auto-renewal** is active but will only renew certs for domains with direct DNS (not behind ArvanCloud CDN). +7. **No automated backups** — Docker volumes for databases are not backed up. Consider setting up periodic `mysqldump` cron jobs. +8. **Portainer times out** if admin account isn't created quickly after restart. Run `docker restart portainer` and immediately access the UI. diff --git a/package.json b/package.json index 0cdf66b6..4428b7aa 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,11 @@ "dev": "next dev --turbopack", "build": "next build && next-sitemap", "start": "next start", - "lint": "eslint" + "lint": "eslint", + "db:generate": "prisma generate", + "db:push": "prisma db push", + "db:migrate": "prisma migrate deploy", + "db:studio": "prisma studio" }, "dependencies": { "next": "16.2.4", @@ -23,15 +27,21 @@ "clsx": "^2.1.1", "tailwind-merge": "^3.0.0", "lucide-react": "^0.501.0", - "next-sanity": "^9.0.0", - "sanity": "^3.0.0", - "@sanity/image-url": "^1.1.0", - "@portabletext/react": "^3.1.0", - "sugar-high": "^0.9.0", "next-sitemap": "^4.2.3", "resend": "^4.0.0", "@radix-ui/react-accordion": "^1.2.0", - "@radix-ui/react-tabs": "^1.1.0" + "@radix-ui/react-tabs": "^1.1.0", + "@prisma/client": "^6.0.0", + "iron-session": "^8.0.0", + "bcryptjs": "^2.4.3", + "@tiptap/react": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/extension-link": "^2.11.0", + "@tiptap/extension-image": "^2.11.0", + "@tiptap/extension-text-direction": "^2.11.0", + "@tiptap/extension-character-count": "^2.11.0" }, "pnpm": { "onlyBuiltDependencies": ["@parcel/watcher", "@swc/core"] @@ -41,9 +51,11 @@ "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/bcryptjs": "^2.4.6", "eslint": "^9", "eslint-config-next": "16.2.4", "tailwindcss": "^4", - "typescript": "^5" + "typescript": "^5", + "prisma": "^6.0.0" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 00000000..e55e9a1f --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,153 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model Post { + id String @id @default(cuid()) + title String @db.VarChar(500) + slug String @unique @db.VarChar(500) + locale String @default("fa") @db.VarChar(5) + category String @db.VarChar(100) + excerpt String? @db.Text + body String? @db.LongText + coverImage String? @db.VarChar(1000) + featured Boolean @default(false) + publishedAt DateTime @default(now()) + seoTitle String? @db.VarChar(255) + seoDesc String? @db.VarChar(500) + tags PostTag[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([locale, featured]) + @@index([locale, category]) + @@index([publishedAt(sort: Desc)]) +} + +model Project { + id String @id @default(cuid()) + title String @db.VarChar(500) + slug String @unique @db.VarChar(500) + locale String @default("fa") @db.VarChar(5) + projectType String @db.VarChar(100) + description String? @db.Text + body String? @db.LongText + coverImage String? @db.VarChar(1000) + gallery String? @db.Text + techStack String? @db.Text + toolsUsed String? @db.Text + liveUrl String? @db.VarChar(500) + githubUrl String? @db.VarChar(500) + featured Boolean @default(false) + seoTitle String? @db.VarChar(255) + seoDesc String? @db.VarChar(500) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([locale, projectType]) + @@index([locale, featured]) +} + +model Product { + id String @id @default(cuid()) + title String @db.VarChar(500) + slug String @unique @db.VarChar(500) + locale String @default("fa") @db.VarChar(5) + description String? @db.Text + price Float? + currency String @default("IRR") @db.VarChar(10) + coverImage String? @db.VarChar(1000) + purchaseUrl String? @db.VarChar(500) + productType String @default("digital") @db.VarChar(50) + featured Boolean @default(false) + seoTitle String? @db.VarChar(255) + seoDesc String? @db.VarChar(500) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([locale]) +} + +model Tag { + id String @id @default(cuid()) + title String @db.VarChar(100) + slug String @unique @db.VarChar(100) + posts PostTag[] +} + +model PostTag { + postId String + tagId String + post Post @relation(fields: [postId], references: [id], onDelete: Cascade) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade) + + @@id([postId, tagId]) +} + +model TimelineEvent { + id String @id @default(cuid()) + title String @db.VarChar(500) + date DateTime + description String? @db.Text + icon String? @db.VarChar(10) + category String @default("career") @db.VarChar(50) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + + @@index([date(sort: Desc)]) +} + +model UsesItem { + id String @id @default(cuid()) + title String @db.VarChar(255) + description String? @db.Text + category String @db.VarChar(50) + url String? @db.VarChar(500) + image String? @db.VarChar(1000) + sortOrder Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) +} + +model SiteSettings { + id String @id @default("main") + siteTitle String @default("Ali Taghavi") @db.VarChar(255) + description String? @db.Text + currentStatus String? @db.Text + telegramChannel String? @db.VarChar(255) + socialGithub String? @db.VarChar(255) + socialLinkedin String? @db.VarChar(255) + socialTwitter String? @db.VarChar(255) + socialTelegram String? @db.VarChar(255) + socialInstagram String? @db.VarChar(255) + updatedAt DateTime @updatedAt +} + +model AdminUser { + id String @id @default(cuid()) + username String @unique @db.VarChar(100) + password String @db.VarChar(255) + createdAt DateTime @default(now()) +} + +model ContactMessage { + id String @id @default(cuid()) + name String @db.VarChar(255) + email String @db.VarChar(255) + subject String? @db.VarChar(500) + message String @db.LongText + isRead Boolean @default(false) + createdAt DateTime @default(now()) + + @@index([isRead]) + @@index([createdAt(sort: Desc)]) +} diff --git a/sanity/sanity.config.ts b/sanity/sanity.config.ts deleted file mode 100644 index cfce784f..00000000 --- a/sanity/sanity.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { defineConfig } from "sanity"; -import { structureTool } from "sanity/structure"; -import * as schemas from "./schemas"; - -const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!; -const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production"; - -export default defineConfig({ - name: "biztaghavi", - title: "BizTaghavi Studio", - projectId, - dataset, - basePath: "/studio", - plugins: [ - structureTool({ - structure: (S) => - S.list() - .title("محتوا") - .items([ - S.documentTypeListItem("post").title("نوشته‌ها"), - S.documentTypeListItem("project").title("پروژه‌ها"), - S.documentTypeListItem("product").title("محصولات"), - S.divider(), - S.documentTypeListItem("author").title("نویسنده"), - S.documentTypeListItem("category").title("دسته‌بندی‌ها"), - S.documentTypeListItem("tag").title("تگ‌ها"), - S.divider(), - S.documentTypeListItem("timelineEvent").title("رویدادهای تایم‌لاین"), - S.documentTypeListItem("usesItem").title("ابزارها"), - S.divider(), - S.documentTypeListItem("siteSettings").title("تنظیمات سایت"), - ]), - }), - ], - schema: { types: Object.values(schemas) }, -}); diff --git a/sanity/schemas/author.ts b/sanity/schemas/author.ts deleted file mode 100644 index 9239d400..00000000 --- a/sanity/schemas/author.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const author = defineType({ - name: "author", - title: "Author", - type: "document", - fields: [ - defineField({ name: "name", title: "نام", type: "string", validation: (r) => r.required() }), - defineField({ name: "bio", title: "بیوگرافی", type: "text" }), - defineField({ name: "image", title: "تصویر", type: "image", options: { hotspot: true } }), - defineField({ - name: "socialLinks", - title: "لینک‌های اجتماعی", - type: "object", - fields: [ - defineField({ name: "github", type: "url", title: "GitHub" }), - defineField({ name: "linkedin", type: "url", title: "LinkedIn" }), - defineField({ name: "twitter", type: "url", title: "Twitter/X" }), - defineField({ name: "telegram", type: "url", title: "Telegram" }), - defineField({ name: "instagram", type: "url", title: "Instagram" }), - ], - }), - ], - preview: { select: { title: "name", media: "image" } }, -}); diff --git a/sanity/schemas/category.ts b/sanity/schemas/category.ts deleted file mode 100644 index 308f4803..00000000 --- a/sanity/schemas/category.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const category = defineType({ - name: "category", - title: "Category", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title" }, validation: (r) => r.required() }), - defineField({ name: "description", title: "توضیح", type: "text" }), - defineField({ name: "icon", title: "آیکون (emoji)", type: "string" }), - ], - preview: { select: { title: "title", subtitle: "description" } }, -}); diff --git a/sanity/schemas/index.ts b/sanity/schemas/index.ts deleted file mode 100644 index f4bc958b..00000000 --- a/sanity/schemas/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { post } from "./post"; -export { project } from "./project"; -export { product } from "./product"; -export { author } from "./author"; -export { category } from "./category"; -export { tag } from "./tag"; -export { siteSettings } from "./siteSettings"; -export { timelineEvent } from "./timelineEvent"; -export { usesItem } from "./usesItem"; diff --git a/sanity/schemas/post.ts b/sanity/schemas/post.ts deleted file mode 100644 index fb1f733d..00000000 --- a/sanity/schemas/post.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const post = defineType({ - name: "post", - title: "Post", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title" }, validation: (r) => r.required() }), - defineField({ - name: "locale", - title: "زبان", - type: "string", - options: { list: [{ value: "fa", title: "فارسی" }, { value: "en", title: "English" }] }, - initialValue: "fa", - validation: (r) => r.required(), - }), - defineField({ - name: "category", - title: "دسته‌بندی", - type: "string", - options: { - list: [ - { value: "founder-notes", title: "یادداشت‌های بنیان‌گذار" }, - { value: "marketing-branding", title: "بازاریابی و برندینگ" }, - { value: "product-thinking", title: "تفکر محصول" }, - { value: "tech-builds", title: "ساخت‌های فنی" }, - { value: "business-experiments", title: "آزمایش‌های کسب‌وکار" }, - { value: "systems-productivity", title: "سیستم‌ها و بهره‌وری" }, - ], - }, - validation: (r) => r.required(), - }), - defineField({ name: "excerpt", title: "خلاصه", type: "text", rows: 3 }), - defineField({ name: "coverImage", title: "تصویر کاور", type: "image", options: { hotspot: true }, fields: [defineField({ name: "alt", type: "string", title: "Alt text" })] }), - defineField({ name: "body", title: "متن", type: "array", of: [{ type: "block" }, { type: "image", options: { hotspot: true } }, { type: "code" }] }), - defineField({ name: "tags", title: "تگ‌ها", type: "array", of: [{ type: "reference", to: [{ type: "tag" }] }] }), - defineField({ name: "author", title: "نویسنده", type: "reference", to: [{ type: "author" }] }), - defineField({ name: "publishedAt", title: "تاریخ انتشار", type: "datetime" }), - defineField({ name: "featured", title: "برگزیده", type: "boolean", initialValue: false }), - defineField({ - name: "seo", - title: "SEO", - type: "object", - fields: [ - defineField({ name: "title", type: "string", title: "عنوان SEO" }), - defineField({ name: "description", type: "text", title: "توضیحات", rows: 2 }), - defineField({ name: "ogImage", type: "image", title: "تصویر OG" }), - ], - }), - ], - preview: { - select: { title: "title", subtitle: "category", media: "coverImage" }, - }, - orderings: [{ title: "تاریخ انتشار", name: "publishedAtDesc", by: [{ field: "publishedAt", direction: "desc" }] }], -}); diff --git a/sanity/schemas/product.ts b/sanity/schemas/product.ts deleted file mode 100644 index cc7189c1..00000000 --- a/sanity/schemas/product.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const product = defineType({ - name: "product", - title: "Product", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title" }, validation: (r) => r.required() }), - defineField({ name: "locale", title: "زبان", type: "string", options: { list: [{ value: "fa", title: "فارسی" }, { value: "en", title: "English" }] }, initialValue: "fa" }), - defineField({ name: "description", title: "توضیح", type: "text", rows: 3 }), - defineField({ name: "price", title: "قیمت", type: "number" }), - defineField({ name: "currency", title: "واحد پول", type: "string", options: { list: [{ value: "IRR", title: "تومان" }, { value: "USD", title: "دلار" }] }, initialValue: "IRR" }), - defineField({ name: "coverImage", title: "تصویر", type: "image", options: { hotspot: true } }), - defineField({ name: "purchaseUrl", title: "لینک خرید", type: "url" }), - defineField({ name: "productType", title: "نوع محصول", type: "string", options: { list: [{ value: "digital", title: "دیجیتال" }, { value: "node-product", title: "محصول NODE" }] } }), - defineField({ name: "featured", title: "برگزیده", type: "boolean", initialValue: false }), - defineField({ - name: "seo", - title: "SEO", - type: "object", - fields: [ - defineField({ name: "title", type: "string", title: "عنوان SEO" }), - defineField({ name: "description", type: "text", title: "توضیحات", rows: 2 }), - ], - }), - ], - preview: { select: { title: "title", subtitle: "productType", media: "coverImage" } }, -}); diff --git a/sanity/schemas/project.ts b/sanity/schemas/project.ts deleted file mode 100644 index ff51d10f..00000000 --- a/sanity/schemas/project.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const project = defineType({ - name: "project", - title: "Project", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title" }, validation: (r) => r.required() }), - defineField({ name: "locale", title: "زبان", type: "string", options: { list: [{ value: "fa", title: "فارسی" }, { value: "en", title: "English" }] }, initialValue: "fa" }), - defineField({ - name: "projectType", - title: "نوع پروژه", - type: "string", - options: { - list: [ - { value: "product", title: "محصول" }, - { value: "brand-system", title: "سیستم برند" }, - { value: "open-source", title: "متن‌باز" }, - { value: "creative-work", title: "اثر خلاقانه" }, - ], - }, - validation: (r) => r.required(), - }), - defineField({ name: "description", title: "توضیح کوتاه", type: "text", rows: 2 }), - defineField({ name: "coverImage", title: "تصویر کاور", type: "image", options: { hotspot: true }, fields: [defineField({ name: "alt", type: "string", title: "Alt text" })] }), - defineField({ name: "gallery", title: "گالری", type: "array", of: [{ type: "image", options: { hotspot: true }, fields: [defineField({ name: "alt", type: "string", title: "Alt text" })] }] }), - defineField({ - name: "body", - title: "محتوا", - type: "array", - of: [{ type: "block" }, { type: "image", options: { hotspot: true } }], - }), - defineField({ name: "techStack", title: "تکنولوژی‌ها", type: "array", of: [{ type: "string" }] }), - defineField({ name: "toolsUsed", title: "ابزارها", type: "array", of: [{ type: "string" }] }), - defineField({ name: "liveUrl", title: "لینک زنده", type: "url" }), - defineField({ name: "githubUrl", title: "GitHub", type: "url" }), - defineField({ name: "featured", title: "برگزیده", type: "boolean", initialValue: false }), - defineField({ - name: "seo", - title: "SEO", - type: "object", - fields: [ - defineField({ name: "title", type: "string", title: "عنوان SEO" }), - defineField({ name: "description", type: "text", title: "توضیحات", rows: 2 }), - defineField({ name: "ogImage", type: "image", title: "تصویر OG" }), - ], - }), - ], - preview: { select: { title: "title", subtitle: "projectType", media: "coverImage" } }, -}); diff --git a/sanity/schemas/siteSettings.ts b/sanity/schemas/siteSettings.ts deleted file mode 100644 index 9ed411b8..00000000 --- a/sanity/schemas/siteSettings.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const siteSettings = defineType({ - name: "siteSettings", - title: "تنظیمات سایت", - type: "document", - fields: [ - defineField({ name: "siteTitle", title: "عنوان سایت", type: "string" }), - defineField({ name: "description", title: "توضیح", type: "text" }), - defineField({ name: "currentStatus", title: "وضعیت فعلی (در حال کار روی...)", type: "string" }), - defineField({ name: "telegramChannel", title: "کانال تلگرام", type: "url" }), - defineField({ name: "defaultOgImage", title: "تصویر OG پیش‌فرض", type: "image" }), - defineField({ - name: "socialLinks", - title: "لینک‌های اجتماعی", - type: "object", - fields: [ - defineField({ name: "github", type: "url", title: "GitHub" }), - defineField({ name: "linkedin", type: "url", title: "LinkedIn" }), - defineField({ name: "twitter", type: "url", title: "Twitter/X" }), - defineField({ name: "telegram", type: "url", title: "Telegram" }), - defineField({ name: "instagram", type: "url", title: "Instagram" }), - ], - }), - ], - preview: { select: { title: "siteTitle" } }, -}); diff --git a/sanity/schemas/tag.ts b/sanity/schemas/tag.ts deleted file mode 100644 index fed0c722..00000000 --- a/sanity/schemas/tag.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const tag = defineType({ - name: "tag", - title: "Tag", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "slug", title: "Slug", type: "slug", options: { source: "title" }, validation: (r) => r.required() }), - ], - preview: { select: { title: "title" } }, -}); diff --git a/sanity/schemas/timelineEvent.ts b/sanity/schemas/timelineEvent.ts deleted file mode 100644 index 8a287596..00000000 --- a/sanity/schemas/timelineEvent.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const timelineEvent = defineType({ - name: "timelineEvent", - title: "Timeline Event", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "date", title: "تاریخ", type: "date" }), - defineField({ name: "description", title: "توضیح", type: "text" }), - defineField({ name: "icon", title: "آیکون (emoji)", type: "string" }), - defineField({ - name: "category", - title: "دسته", - type: "string", - options: { list: [{ value: "career", title: "کار" }, { value: "product", title: "محصول" }, { value: "personal", title: "شخصی" }] }, - }), - ], - orderings: [{ title: "تاریخ", name: "dateDesc", by: [{ field: "date", direction: "desc" }] }], - preview: { select: { title: "title", subtitle: "date" } }, -}); diff --git a/sanity/schemas/usesItem.ts b/sanity/schemas/usesItem.ts deleted file mode 100644 index 48645f8c..00000000 --- a/sanity/schemas/usesItem.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { defineField, defineType } from "sanity"; - -export const usesItem = defineType({ - name: "usesItem", - title: "Uses Item", - type: "document", - fields: [ - defineField({ name: "title", title: "عنوان", type: "string", validation: (r) => r.required() }), - defineField({ name: "description", title: "توضیح", type: "text" }), - defineField({ - name: "category", - title: "دسته", - type: "string", - options: { - list: [ - { value: "development", title: "توسعه" }, - { value: "design", title: "طراحی" }, - { value: "marketing", title: "بازاریابی" }, - { value: "productivity", title: "بهره‌وری" }, - { value: "hardware", title: "سخت‌افزار" }, - ], - }, - validation: (r) => r.required(), - }), - defineField({ name: "url", title: "لینک", type: "url" }), - defineField({ name: "image", title: "تصویر", type: "image" }), - ], - preview: { select: { title: "title", subtitle: "category" } }, -}); diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx index ae7087c4..c3c07e1b 100644 --- a/src/app/[locale]/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -6,10 +6,8 @@ import { AnimatedSection } from "@/components/shared/AnimatedSection"; import { Timeline } from "@/components/about/Timeline"; import { Values } from "@/components/about/Values"; import { FocusAreas } from "@/components/about/FocusAreas"; -import { sanityFetch } from "@/lib/sanity/client"; -import { siteSettingsQuery } from "@/lib/sanity/queries"; -import type { SiteSettings } from "@/lib/sanity/types"; -import { groq } from "next-sanity"; +import { getSiteSettings } from "@/lib/db/settings"; +import { getAllTimelineEvents } from "@/lib/db/timeline"; interface Props { params: Promise<{ locale: string }>; @@ -21,11 +19,7 @@ export async function generateMetadata({ params }: Props): Promise { return { title: t("title"), description: t("subtitle") }; } -const timelineQuery = groq` - *[_type == "timelineEvent"] | order(date desc) { - title, date, description, icon, category - } -`; +export const dynamic = "force-dynamic"; const socialLinks = [ { icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" }, @@ -38,13 +32,38 @@ export default async function AboutPage({ params }: Props) { const { locale } = await params; const [t, settings, timelineEvents] = await Promise.all([ getTranslations({ locale, namespace: "about" }), - sanityFetch(siteSettingsQuery), - sanityFetch(timelineQuery), + getSiteSettings(), + getAllTimelineEvents(), ]); const values = t.raw("values") as { title: string; desc: string }[]; const focusAreas = t.raw("focus_areas") as string[]; + const dbLinks = { + github: settings?.socialGithub, + linkedin: settings?.socialLinkedin, + twitter: settings?.socialTwitter, + telegram: settings?.socialTelegram, + }; + + const links = socialLinks.map((l) => ({ + ...l, + href: + (l.label === "GitHub" && dbLinks.github) || + (l.label === "LinkedIn" && dbLinks.linkedin) || + (l.label === "Twitter/X" && dbLinks.twitter) || + (l.label === "Telegram" && dbLinks.telegram) || + l.href, + })); + + const timelineItems = timelineEvents.map((e) => ({ + title: e.title, + date: new Intl.DateTimeFormat("fa-IR", { year: "numeric" }).format(new Date(e.date)), + description: e.description ?? undefined, + icon: e.icon ?? undefined, + category: (e.category as "career" | "product" | "personal") ?? "career", + })); + return (
@@ -56,16 +75,14 @@ export default async function AboutPage({ params }: Props) {

{t("title")}

{t("bio").split("\n\n").map((paragraph, i) => ( -

- {paragraph} -

+

{paragraph}

))}
- {socialLinks.map(({ icon: Icon, href, label }) => ( + {links.map(({ icon: Icon, href, label }) => ( - [0]["events"]} /> +
); diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx index 61438ca3..cf36d726 100644 --- a/src/app/[locale]/page.tsx +++ b/src/app/[locale]/page.tsx @@ -4,7 +4,7 @@ import { BuildingNow } from "@/components/home/BuildingNow"; import { LatestWriting } from "@/components/home/LatestWriting"; import { SignalCTA } from "@/components/home/SignalCTA"; -export default function HomePage() { +export default function HomePage({ params }: { params: { locale: string } }) { return ( <> @@ -12,7 +12,7 @@ export default function HomePage() { - + diff --git a/src/app/[locale]/shop/page.tsx b/src/app/[locale]/shop/page.tsx index c8eeb247..063578a5 100644 --- a/src/app/[locale]/shop/page.tsx +++ b/src/app/[locale]/shop/page.tsx @@ -6,77 +6,32 @@ import { SectionHeader } from "@/components/shared/SectionHeader"; import { AnimatedSection } from "@/components/shared/AnimatedSection"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { sanityFetch } from "@/lib/sanity/client"; -import { allProductsQuery } from "@/lib/sanity/queries"; -import { urlFor } from "@/lib/sanity/image"; -import type { SanityImage } from "@/lib/sanity/types"; +import { getAllProducts } from "@/lib/db/products"; +import type { Product } from "@prisma/client"; interface Props { params: Promise<{ locale: string }>; } -interface Product { - _id: string; - title: string; - slug: { current: string }; - description?: string; - price?: number; - currency?: string; - productType?: string; - purchaseUrl?: string; - featured?: boolean; - coverImage?: SanityImage; -} - export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: "shop" }); return { title: t("title"), description: t("subtitle") }; } -// Placeholder products shown when Sanity has no data yet -const placeholders: Product[] = [ - { - _id: "p1", - title: "دوره بازاریابی دیجیتال برای استارتاپ‌های ایرانی", - slug: { current: "digital-marketing-course" }, - description: "آموزش جامع استراتژی بازاریابی دیجیتال متناسب با بازار ایران — از صفر تا اجرا", - price: 490000, - currency: "IRR", - productType: "digital", - }, - { - _id: "p2", - title: "قالب استراتژی برند", - slug: { current: "brand-strategy-template" }, - description: "قالب آماده برای طراحی هویت برند و استراتژی بصری کسب‌وکار", - price: 0, - currency: "IRR", - productType: "digital", - }, - { - _id: "p3", - title: "HyperAccount", - slug: { current: "hyperaccount" }, - description: "زیرساخت هویت و حساب کاربری برای محصولات دیجیتال ایرانی", - productType: "node-product", - purchaseUrl: "https://hyperaccount.ir", - }, -]; +export const dynamic = "force-dynamic"; export default async function ShopPage({ params }: Props) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "shop" }); const isRtl = locale === "fa"; - const sanityProducts = await sanityFetch(allProductsQuery, { locale }); - const products = sanityProducts && sanityProducts.length > 0 ? sanityProducts : placeholders; + const products = await getAllProducts(locale); - function formatPrice(price: number, currency: string) { + function formatPrice(price: number | null, currency: string) { + if (price === null || price === undefined) return null; if (price === 0) return isRtl ? t("free") : "Free"; - if (currency === "IRR") { - return new Intl.NumberFormat("fa-IR").format(price) + " تومان"; - } + if (currency === "IRR") return new Intl.NumberFormat("fa-IR").format(price) + " تومان"; return `$${price}`; } @@ -88,67 +43,67 @@ export default async function ShopPage({ params }: Props) {

{t("subtitle")}

-
- {products.map((product, i) => { - const imageUrl = product.coverImage?.asset - ? urlFor(product.coverImage).width(600).height(340).url() - : null; + {products.length === 0 ? ( +
+

به زودی محصولات اضافه می‌شود.

+
+ ) : ( +
+ {products.map((product: Product, i) => { + const priceStr = formatPrice(product.price, product.currency); - return ( - -
- {/* Image or placeholder */} - {imageUrl ? ( -
- {product.title} -
- ) : ( -
- // -
- )} - -
-
- - {product.productType === "node-product" ? t("node_product") : t("digital")} - - {product.featured && ( - - )} -
- -

{product.title}

- - {product.description && ( -

- {product.description} -

+ return ( + +
+ {/* Image */} + {product.coverImage ? ( +
+ {product.title} +
+ ) : ( +
+ // +
)} -
- {product.price !== undefined && ( - - {formatPrice(product.price, product.currency ?? "IRR")} - - )} - {product.purchaseUrl ? ( - - ) : ( - +
+
+ + {product.productType === "node-product" ? t("node_product") : t("digital")} + + {product.featured && } +
+ +

{product.title}

+ + {product.description && ( +

+ {product.description} +

)} + +
+ {priceStr && ( + {priceStr} + )} + {product.purchaseUrl ? ( + + ) : ( + + )} +
-
- - ); - })} -
+ + ); + })} +
+ )}
); } diff --git a/src/app/[locale]/uses/page.tsx b/src/app/[locale]/uses/page.tsx index 482548ff..df78e4f7 100644 --- a/src/app/[locale]/uses/page.tsx +++ b/src/app/[locale]/uses/page.tsx @@ -3,8 +3,7 @@ import { getTranslations } from "next-intl/server"; import { ExternalLink } from "lucide-react"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { AnimatedSection } from "@/components/shared/AnimatedSection"; -import { sanityFetch } from "@/lib/sanity/client"; -import { allUsesItemsQuery } from "@/lib/sanity/queries"; +import { getAllUsesItems } from "@/lib/db/uses"; interface Props { params: Promise<{ locale: string }>; @@ -12,32 +11,13 @@ interface Props { type UsesCategory = "development" | "design" | "marketing" | "productivity" | "hardware"; -interface UsesItem { - _id: string; - title: string; - description?: string; - category: UsesCategory; - url?: string; -} - export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: "uses" }); return { title: t("title"), description: t("subtitle") }; } -// Static fallback — shown until Sanity has data -const fallbackItems: UsesItem[] = [ - { _id: "1", title: "VS Code", description: "اصلی‌ترین ابزار کدنویسی‌ام", category: "development", url: "https://code.visualstudio.com" }, - { _id: "2", title: "Next.js", description: "فریم‌ورک React برای وب اپلیکیشن‌ها", category: "development", url: "https://nextjs.org" }, - { _id: "3", title: "Sanity", description: "هدلس CMS برای مدیریت محتوا", category: "development", url: "https://sanity.io" }, - { _id: "4", title: "Docker", description: "کانتینرایزیشن برای دپلوی", category: "development" }, - { _id: "5", title: "Figma", description: "طراحی UI/UX و هویت بصری", category: "design", url: "https://figma.com" }, - { _id: "6", title: "Linear", description: "مدیریت پروژه و تسک‌ها", category: "productivity", url: "https://linear.app" }, - { _id: "7", title: "Notion", description: "مستندسازی و یادداشت‌برداری", category: "productivity", url: "https://notion.so" }, - { _id: "8", title: "macOS", description: "سیستم‌عامل اصلی", category: "hardware" }, - { _id: "9", title: "Google Analytics + Umami", description: "آنالیتیکس سایت — هر دو برای مقایسه", category: "marketing" }, -]; +export const dynamic = "force-dynamic"; const categoryOrder: UsesCategory[] = ["development", "design", "marketing", "productivity", "hardware"]; @@ -46,10 +26,9 @@ export default async function UsesPage({ params }: Props) { const t = await getTranslations({ locale, namespace: "uses" }); const tCat = await getTranslations({ locale, namespace: "uses.categories" }); - const sanityItems = await sanityFetch(allUsesItemsQuery); - const items = sanityItems && sanityItems.length > 0 ? sanityItems : fallbackItems; + const items = await getAllUsesItems(); - const grouped = categoryOrder.reduce>((acc, cat) => { + const grouped = categoryOrder.reduce>((acc, cat) => { const catItems = items.filter((i) => i.category === cat); if (catItems.length > 0) acc[cat] = catItems; return acc; @@ -73,7 +52,7 @@ export default async function UsesPage({ params }: Props) {
{catItems.map((item) => (
@@ -98,6 +77,9 @@ export default async function UsesPage({ params }: Props) {
))} + {Object.keys(grouped).length === 0 && ( +

هنوز ابزاری اضافه نشده.

+ )}
); diff --git a/src/app/[locale]/work/[slug]/page.tsx b/src/app/[locale]/work/[slug]/page.tsx index 88876e2f..524f1345 100644 --- a/src/app/[locale]/work/[slug]/page.tsx +++ b/src/app/[locale]/work/[slug]/page.tsx @@ -4,37 +4,36 @@ import Image from "next/image"; import { getTranslations } from "next-intl/server"; import { ExternalLink, Github, ArrowLeft, ArrowRight } from "lucide-react"; import { Link } from "@/lib/i18n/navigation"; -import { sanityFetch } from "@/lib/sanity/client"; -import { projectBySlugQuery, allProjectSlugsQuery } from "@/lib/sanity/queries"; -import { urlFor } from "@/lib/sanity/image"; -import { PortableTextRenderer } from "@/components/writing/PortableTextRenderer"; +import { getProjectBySlug, getAllProjectSlugs } from "@/lib/db/projects"; +import { TiptapRenderer } from "@/components/writing/TiptapRenderer"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import type { Project } from "@/lib/sanity/types"; interface Props { params: Promise<{ locale: string; slug: string }>; } export async function generateStaticParams() { - const slugs = await sanityFetch<{ slug: string }[]>(allProjectSlugsQuery); - return (slugs ?? []).map((s) => ({ slug: s.slug })); + const slugs = await getAllProjectSlugs(); + return slugs.map((s) => ({ slug: s.slug })); } export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; - const project = await sanityFetch(projectBySlugQuery, { slug }); + const project = await getProjectBySlug(slug); if (!project) return {}; return { - title: project.seo?.title ?? project.title, - description: project.seo?.description ?? project.description, + title: project.seoTitle ?? project.title, + description: project.seoDesc ?? project.description ?? undefined, }; } +export const dynamic = "force-dynamic"; + export default async function ProjectPage({ params }: Props) { const { locale, slug } = await params; const [project, t, tWork] = await Promise.all([ - sanityFetch(projectBySlugQuery, { slug }), + getProjectBySlug(slug), getTranslations({ locale, namespace: "common" }), getTranslations({ locale, namespace: "work" }), ]); @@ -43,9 +42,6 @@ export default async function ProjectPage({ params }: Props) { const isRtl = locale === "fa"; const BackArrow = isRtl ? ArrowRight : ArrowLeft; - const coverUrl = project.coverImage?.asset - ? urlFor(project.coverImage).width(1400).height(600).url() - : null; return (
@@ -60,10 +56,10 @@ export default async function ProjectPage({ params }: Props) {
{/* Cover */} - {coverUrl && ( + {project.coverImage && (
- {project.coverImage?.alt -
+ {project.title} +
)} @@ -96,23 +92,23 @@ export default async function ProjectPage({ params }: Props) { {/* Body */} - {project.body && } + {project.body && } {/* Tech stack + tools */}
- {project.techStack && project.techStack.length > 0 && ( + {project.techStack.length > 0 && (

// {tWork("tech_stack")}

- {project.techStack.map((t) => ( - - {t} + {project.techStack.map((tech) => ( + + {tech} ))}
)} - {project.toolsUsed && project.toolsUsed.length > 0 && ( + {project.toolsUsed.length > 0 && (

// {tWork("tools")}

@@ -127,18 +123,13 @@ export default async function ProjectPage({ params }: Props) {
{/* Gallery */} - {project.gallery && project.gallery.length > 0 && ( + {project.gallery.length > 0 && (

// gallery

- {project.gallery.map((img, i) => ( + {project.gallery.map((imgUrl, i) => (
- {img.alt + {`Gallery
))}
diff --git a/src/app/[locale]/work/page.tsx b/src/app/[locale]/work/page.tsx index c3d98b6b..de3c90b2 100644 --- a/src/app/[locale]/work/page.tsx +++ b/src/app/[locale]/work/page.tsx @@ -2,9 +2,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { WorkListClient } from "@/components/work/WorkListClient"; -import { sanityFetch } from "@/lib/sanity/client"; -import { allProjectsQuery } from "@/lib/sanity/queries"; -import type { Project } from "@/lib/sanity/types"; +import { getAllProjects } from "@/lib/db/projects"; interface Props { params: Promise<{ locale: string }>; @@ -16,11 +14,12 @@ export async function generateMetadata({ params }: Props): Promise { return { title: t("title"), description: t("subtitle") }; } +export const dynamic = "force-dynamic"; + export default async function WorkPage({ params }: Props) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "work" }); - - const projects = await sanityFetch(allProjectsQuery, { locale }); + const projects = await getAllProjects(locale); return (
@@ -29,7 +28,7 @@ export default async function WorkPage({ params }: Props) {

{t("title")}

{t("subtitle")}

- +
); } diff --git a/src/app/[locale]/writing/[slug]/page.tsx b/src/app/[locale]/writing/[slug]/page.tsx index dd3a3e2a..bb1b3081 100644 --- a/src/app/[locale]/writing/[slug]/page.tsx +++ b/src/app/[locale]/writing/[slug]/page.tsx @@ -4,49 +4,44 @@ import Image from "next/image"; import { getTranslations } from "next-intl/server"; import { ArrowRight, ArrowLeft, Clock } from "lucide-react"; import { Link } from "@/lib/i18n/navigation"; -import { sanityFetch } from "@/lib/sanity/client"; -import { postBySlugQuery, relatedPostsQuery, allPostSlugsQuery } from "@/lib/sanity/queries"; -import { urlFor } from "@/lib/sanity/image"; -import { formatDate, estimateReadTime } from "@/lib/sanity/utils"; -import { PortableTextRenderer } from "@/components/writing/PortableTextRenderer"; +import { getPostBySlug, getRelatedPosts, getAllPostSlugs, estimateReadTime } from "@/lib/db/posts"; +import { TiptapRenderer } from "@/components/writing/TiptapRenderer"; import { ReadingProgress } from "@/components/writing/ReadingProgress"; import { ShareButtons } from "@/components/writing/ShareButtons"; import { PostCard } from "@/components/writing/PostCard"; import { Badge } from "@/components/ui/badge"; -import type { Post } from "@/lib/sanity/types"; +import { formatDate } from "@/lib/types"; interface Props { params: Promise<{ locale: string; slug: string }>; } export async function generateStaticParams() { - const slugs = await sanityFetch<{ slug: string; locale: string }[]>(allPostSlugsQuery); - return (slugs ?? []).map((s) => ({ slug: s.slug })); + const slugs = await getAllPostSlugs(); + return slugs.map((s) => ({ slug: s.slug })); } export async function generateMetadata({ params }: Props): Promise { const { slug } = await params; - const post = await sanityFetch(postBySlugQuery, { slug }); + const post = await getPostBySlug(slug); if (!post) return {}; return { - title: post.seo?.title ?? post.title, - description: post.seo?.description ?? post.excerpt, + title: post.seoTitle ?? post.title, + description: post.seoDesc ?? post.excerpt ?? undefined, openGraph: { - title: post.seo?.title ?? post.title, - description: post.seo?.description ?? post.excerpt, - images: post.seo?.ogImage?.url - ? [{ url: post.seo.ogImage.url }] - : post.coverImage?.asset - ? [{ url: urlFor(post.coverImage).width(1200).height(630).url() }] - : [], + title: post.seoTitle ?? post.title, + description: post.seoDesc ?? post.excerpt ?? undefined, + images: post.coverImage ? [{ url: post.coverImage }] : [], }, }; } +export const dynamic = "force-dynamic"; + export default async function PostPage({ params }: Props) { const { locale, slug } = await params; const [post, t, tCommon, tCat] = await Promise.all([ - sanityFetch(postBySlugQuery, { slug }), + getPostBySlug(slug), getTranslations({ locale, namespace: "writing" }), getTranslations({ locale, namespace: "common" }), getTranslations({ locale, namespace: "writing.categories" }), @@ -54,20 +49,16 @@ export default async function PostPage({ params }: Props) { if (!post) notFound(); - const related = await sanityFetch(relatedPostsQuery, { - locale, - category: post.category, - slug, - }); - - const readTime = post.body ? estimateReadTime(post.body as unknown[]) : null; - const coverUrl = post.coverImage?.asset - ? urlFor(post.coverImage).width(1400).height(600).url() - : null; + const related = await getRelatedPosts(locale, post.category, slug); + const readTime = post.body ? estimateReadTime(post.body) : null; const postUrl = `https://biztaghavi.com/${locale}/writing/${slug}`; const isRtl = locale === "fa"; const BackArrow = isRtl ? ArrowRight : ArrowLeft; + // Flatten tags + const tags = post.tags.map((pt) => pt.tag); + const flatRelated = related.map((p) => ({ ...p, tags: p.tags.map((pt) => pt.tag) })); + return ( <> @@ -107,39 +98,34 @@ export default async function PostPage({ params }: Props) {

{post.excerpt}

)} -
- {post.author && ( -
- {post.author.name} -
- )} +
{/* Cover image */} - {coverUrl && ( + {post.coverImage && (
{post.coverImage?.alt -
+
)} {/* Body */}
- {post.body && } + {post.body && } {/* Tags */} - {post.tags && post.tags.length > 0 && ( + {tags.length > 0 && (
- {post.tags.map((tag) => ( - {tag.title} + {tags.map((tag) => ( + {tag.title} ))}
)} @@ -151,13 +137,13 @@ export default async function PostPage({ params }: Props) {
{/* Related posts */} - {related && related.length > 0 && ( + {flatRelated.length > 0 && (

// {tCommon("related_posts")}

- {related.map((p) => ( - + {flatRelated.map((p) => ( + ))}
diff --git a/src/app/[locale]/writing/page.tsx b/src/app/[locale]/writing/page.tsx index a04033df..5e4ee0d8 100644 --- a/src/app/[locale]/writing/page.tsx +++ b/src/app/[locale]/writing/page.tsx @@ -2,9 +2,7 @@ import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { SectionHeader } from "@/components/shared/SectionHeader"; import { WritingListClient } from "@/components/writing/WritingListClient"; -import { sanityFetch } from "@/lib/sanity/client"; -import { allPostsQuery } from "@/lib/sanity/queries"; -import type { Post } from "@/lib/sanity/types"; +import { getAllPosts } from "@/lib/db/posts"; interface Props { params: Promise<{ locale: string }>; @@ -13,17 +11,21 @@ interface Props { export async function generateMetadata({ params }: Props): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: "writing" }); - return { - title: t("title"), - description: t("subtitle"), - }; + return { title: t("title"), description: t("subtitle") }; } +export const dynamic = "force-dynamic"; + export default async function WritingPage({ params }: Props) { const { locale } = await params; const t = await getTranslations({ locale, namespace: "writing" }); + const posts = await getAllPosts(locale); - const posts = await sanityFetch(allPostsQuery, { locale }); + // Flatten tags for the component + const flatPosts = posts.map((p) => ({ + ...p, + tags: p.tags.map((pt) => pt.tag), + })); return (
@@ -32,7 +34,7 @@ export default async function WritingPage({ params }: Props) {

{t("title")}

{t("subtitle")}

- +
); } diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx new file mode 100644 index 00000000..f27222be --- /dev/null +++ b/src/app/admin/layout.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; +import { AdminSidebar } from "@/components/admin/AdminSidebar"; +import { prisma } from "@/lib/db"; + +export const metadata: Metadata = { + title: { default: "Admin Panel", template: "%s — Admin" }, + robots: { index: false, follow: false }, +}; + +export default async function AdminLayout({ children }: { children: React.ReactNode }) { + const session = await getSession(); + + // Login page is exempt + return ( +
+ {session.isLoggedIn ? ( + {children} + ) : ( + children + )} +
+ ); +} + +async function AuthedLayout({ + children, + username, +}: { + children: React.ReactNode; + username?: string; +}) { + const unreadCount = await prisma.contactMessage.count({ where: { isRead: false } }); + + return ( + <> + +
+
{children}
+
+ + ); +} diff --git a/src/app/admin/login/page.tsx b/src/app/admin/login/page.tsx new file mode 100644 index 00000000..dd8a0a2b --- /dev/null +++ b/src/app/admin/login/page.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Eye, EyeOff, Loader2, Lock } from "lucide-react"; + +export default function AdminLoginPage() { + const router = useRouter(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPass, setShowPass] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("/api/admin/auth", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + const data = await res.json(); + if (!res.ok) { setError(data.error); return; } + router.push("/admin"); + router.refresh(); + } catch { + setError("خطای اتصال"); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* Logo */} +
+
+ +
+

پنل مدیریت

+

biztaghavi.com

+
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} + +
+ + setUsername(e.target.value)} + required + autoComplete="username" + className="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20" + /> +
+ +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + className="w-full rounded-lg border border-border bg-background px-4 py-2.5 pe-10 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/20" + /> + +
+
+ + +
+
+
+ ); +} diff --git a/src/app/admin/messages/page.tsx b/src/app/admin/messages/page.tsx new file mode 100644 index 00000000..6cbf2b86 --- /dev/null +++ b/src/app/admin/messages/page.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Loader2, Mail, MailOpen, Trash2, Circle } from "lucide-react"; +import { formatDate } from "@/lib/types"; + +interface Message { + id: string; name: string; email: string; + subject: string | null; message: string; + isRead: boolean; createdAt: string; +} + +export default function AdminMessagesPage() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [selected, setSelected] = useState(null); + + async function load() { + const res = await fetch("/api/admin/messages"); + setMessages(await res.json()); + setLoading(false); + } + useEffect(() => { load(); }, []); + + async function markRead(id: string, isRead: boolean) { + await fetch(`/api/admin/messages/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ isRead }), + }); + setMessages((prev) => prev.map((m) => m.id === id ? { ...m, isRead } : m)); + if (selected?.id === id) setSelected((p) => p ? { ...p, isRead } : p); + } + + async function handleDelete(id: string) { + if (!confirm("پیام حذف شود؟")) return; + await fetch(`/api/admin/messages/${id}`, { method: "DELETE" }); + setMessages((prev) => prev.filter((m) => m.id !== id)); + if (selected?.id === id) setSelected(null); + } + + function handleSelect(msg: Message) { + setSelected(msg); + if (!msg.isRead) markRead(msg.id, true); + } + + const unread = messages.filter((m) => !m.isRead).length; + + return ( +
+
+

پیام‌ها

+ {unread > 0 &&

{unread} پیام خوانده‌نشده

} +
+ + {loading ? ( +
+ ) : messages.length === 0 ? ( +
+ +

هنوز پیامی دریافت نشده

+
+ ) : ( +
+ {/* List */} +
+ {messages.map((msg) => ( + + ))} +
+ + {/* Detail */} + {selected ? ( +
+
+
+

{selected.subject ?? "(بدون موضوع)"}

+
+ {selected.name} + {selected.email} +
+
{formatDate(selected.createdAt, "fa")}
+
+
+ + +
+
+
+

{selected.message}

+
+ + پاسخ دادن + +
+ ) : ( +
+

یک پیام را انتخاب کنید

+
+ )} +
+ )} +
+ ); +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 00000000..a43c276e --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,132 @@ +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth"; +import { prisma } from "@/lib/db"; +import { FileText, Briefcase, ShoppingBag, Mail, Clock, Wrench } from "lucide-react"; +import { formatDate } from "@/lib/types"; + +export const dynamic = "force-dynamic"; + +export default async function AdminDashboard() { + const session = await getSession(); + if (!session.isLoggedIn) redirect("/admin/login"); + + const [postCount, projectCount, productCount, timelineCount, usesCount, unreadMessages, recentMessages, recentPosts] = + await Promise.all([ + prisma.post.count(), + prisma.project.count(), + prisma.product.count(), + prisma.timelineEvent.count(), + prisma.usesItem.count(), + prisma.contactMessage.count({ where: { isRead: false } }), + prisma.contactMessage.findMany({ orderBy: { createdAt: "desc" }, take: 5 }), + prisma.post.findMany({ orderBy: { publishedAt: "desc" }, take: 5 }), + ]); + + const stats = [ + { label: "نوشته‌ها", value: postCount, icon: FileText, href: "/admin/posts", color: "text-blue-400" }, + { label: "پروژه‌ها", value: projectCount, icon: Briefcase, href: "/admin/projects", color: "text-purple-400" }, + { label: "محصولات", value: productCount, icon: ShoppingBag, href: "/admin/products", color: "text-green-400" }, + { label: "رویدادهای تایم‌لاین", value: timelineCount, icon: Clock, href: "/admin/timeline", color: "text-orange-400" }, + { label: "ابزارها", value: usesCount, icon: Wrench, href: "/admin/uses", color: "text-yellow-400" }, + { + label: "پیام‌های خوانده‌نشده", + value: unreadMessages, + icon: Mail, + href: "/admin/messages", + color: unreadMessages > 0 ? "text-accent" : "text-text-secondary", + }, + ]; + + return ( +
+
+

داشبورد

+

خوش آمدی، {session.username} 👋

+
+ + {/* Stats grid */} +
+ {stats.map((stat) => ( + +
+
+
{stat.value}
+
{stat.label}
+
+
+ +
+
+
+ ))} +
+ +
+ {/* Recent posts */} +
+
+

آخرین نوشته‌ها

+ + جدید +
+ {recentPosts.length === 0 ? ( +

هنوز نوشته‌ای ندارید

+ ) : ( + + )} +
+ + {/* Recent messages */} +
+
+

پیام‌های اخیر

+ مشاهده همه +
+ {recentMessages.length === 0 ? ( +

هنوز پیامی دریافت نشده

+ ) : ( + +
+
+ ); +} diff --git a/src/app/admin/posts/PostForm.tsx b/src/app/admin/posts/PostForm.tsx new file mode 100644 index 00000000..45d5e8e0 --- /dev/null +++ b/src/app/admin/posts/PostForm.tsx @@ -0,0 +1,262 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Loader2, Save, Eye } from "lucide-react"; +import { RichTextEditor } from "@/components/admin/RichTextEditor"; +import { ImageUpload } from "@/components/admin/ImageUpload"; +import { TagsInput } from "@/components/admin/TagsInput"; +import { slugify } from "@/lib/types"; + +const CATEGORIES = [ + { value: "founder-notes", label: "یادداشت‌های بنیان‌گذار" }, + { value: "marketing-branding", label: "بازاریابی و برندینگ" }, + { value: "product-thinking", label: "تفکر محصول" }, + { value: "tech-builds", label: "ساخت‌های فنی" }, + { value: "business-experiments", label: "آزمایش‌های کسب‌وکار" }, + { value: "systems-productivity", label: "سیستم‌ها و بهره‌وری" }, +]; + +interface PostFormData { + id?: string; + title: string; + slug: string; + locale: string; + category: string; + excerpt: string; + body: string; + coverImage: string; + featured: boolean; + publishedAt: string; + seoTitle: string; + seoDesc: string; + tagIds: string[]; +} + +interface Props { + initialData?: Partial; +} + +export function PostForm({ initialData }: Props) { + const router = useRouter(); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const [form, setForm] = useState({ + title: "", + slug: "", + locale: "fa", + category: "founder-notes", + excerpt: "", + body: "", + coverImage: "", + featured: false, + publishedAt: new Date().toISOString().split("T")[0], + seoTitle: "", + seoDesc: "", + tagIds: [], + ...initialData, + }); + + function set(key: keyof PostFormData, value: unknown) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function handleTitleChange(title: string) { + set("title", title); + if (!initialData?.id) { + set("slug", slugify(title)); + } + } + + async function handleSave(e: React.FormEvent) { + e.preventDefault(); + if (!form.title || !form.slug || !form.category) { + setError("عنوان، اسلاگ و دسته‌بندی الزامی است"); + return; + } + setError(null); + setSaving(true); + try { + const method = initialData?.id ? "PUT" : "POST"; + const url = initialData?.id ? `/api/admin/posts/${initialData.id}` : "/api/admin/posts"; + const { tagIds, ...rest } = form; + const res = await fetch(url, { + method, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + ...rest, + publishedAt: new Date(form.publishedAt).toISOString(), + tagIds, + }), + }); + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error ?? "Save failed"); + } + setSuccess(true); + setTimeout(() => { + router.push("/admin/posts"); + }, 800); + } catch (e) { + setError(e instanceof Error ? e.message : "خطا در ذخیره"); + } finally { + setSaving(false); + } + } + + return ( +
+ {error && ( +
{error}
+ )} + {success && ( +
ذخیره شد ✓
+ )} + + {/* Basic info */} +
+

اطلاعات اصلی

+ +
+
+ + handleTitleChange(e.target.value)} + required + className="admin-input w-full" + placeholder="عنوان نوشته را وارد کنید..." + /> +
+
+ + set("slug", e.target.value)} + required + dir="ltr" + className="admin-input w-full font-mono text-sm" + /> +
+
+ + +
+
+ + +
+
+ + set("publishedAt", e.target.value)} + dir="ltr" + className="admin-input w-full" + /> +
+
+ +