feat: add admin API routes for tags, timeline events, uploads, and uses management
Some checks failed
Deploy to VPS / deploy (push) Has been cancelled
Some checks failed
Deploy to VPS / deploy (push) Has been cancelled
- 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`.
This commit is contained in:
259
DEPLOY.md
Normal file
259
DEPLOY.md
Normal file
@@ -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@<version> 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=<secret used once to create admin user>
|
||||
RESEND_API_KEY=<from resend.com — optional>
|
||||
CONTACT_EMAIL=ali@biztaghavi.com
|
||||
NEXT_PUBLIC_SITE_URL=https://biztaghavi.com
|
||||
|
||||
# Docker DB credentials (must match DATABASE_URL above)
|
||||
DB_ROOT_PASSWORD=<strong root password>
|
||||
DB_NAME=biztaghavi
|
||||
DB_USER=biztaghavi
|
||||
DB_PASSWORD=<same as DATABASE_URL 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.
|
||||
52
Dockerfile
52
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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
433
nodecloud-server-context.md
Normal file
433
nodecloud-server-context.md
Normal file
@@ -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 <mysql_container> 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.
|
||||
28
package.json
28
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"
|
||||
}
|
||||
}
|
||||
|
||||
153
prisma/schema.prisma
Normal file
153
prisma/schema.prisma
Normal file
@@ -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)])
|
||||
}
|
||||
@@ -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) },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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" }] }],
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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" } },
|
||||
});
|
||||
@@ -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<Metadata> {
|
||||
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<SiteSettings>(siteSettingsQuery),
|
||||
sanityFetch<unknown[]>(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 (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
<SectionHeader label="about" />
|
||||
@@ -56,16 +75,14 @@ export default async function AboutPage({ params }: Props) {
|
||||
<h1 className="mb-6 text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
{t("bio").split("\n\n").map((paragraph, i) => (
|
||||
<p key={i} className="text-base text-text-secondary leading-relaxed">
|
||||
{paragraph}
|
||||
</p>
|
||||
<p key={i} className="text-base text-text-secondary leading-relaxed">{paragraph}</p>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-8 flex items-center gap-3">
|
||||
{socialLinks.map(({ icon: Icon, href, label }) => (
|
||||
{links.map(({ icon: Icon, href, label }) => (
|
||||
<a
|
||||
key={href}
|
||||
href={href}
|
||||
key={label}
|
||||
href={href as string}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={label}
|
||||
@@ -108,7 +125,7 @@ export default async function AboutPage({ params }: Props) {
|
||||
{/* Timeline */}
|
||||
<AnimatedSection>
|
||||
<SectionHeader label={t("journey")} />
|
||||
<Timeline events={timelineEvents as Parameters<typeof Timeline>[0]["events"]} />
|
||||
<Timeline events={timelineItems} />
|
||||
</AnimatedSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Hero />
|
||||
@@ -12,7 +12,7 @@ export default function HomePage() {
|
||||
<BuildingNow />
|
||||
</AnimatedSection>
|
||||
<AnimatedSection delay={0.1}>
|
||||
<LatestWriting />
|
||||
<LatestWriting locale={params.locale} />
|
||||
</AnimatedSection>
|
||||
<AnimatedSection delay={0.2}>
|
||||
<SignalCTA />
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Product[]>(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) {
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</AnimatedSection>
|
||||
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product, i) => {
|
||||
const imageUrl = product.coverImage?.asset
|
||||
? urlFor(product.coverImage).width(600).height(340).url()
|
||||
: null;
|
||||
{products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<p className="text-text-secondary">به زودی محصولات اضافه میشود.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{products.map((product: Product, i) => {
|
||||
const priceStr = formatPrice(product.price, product.currency);
|
||||
|
||||
return (
|
||||
<AnimatedSection key={product._id} delay={i * 0.07}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
|
||||
{/* Image or placeholder */}
|
||||
{imageUrl ? (
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<Image src={imageUrl} alt={product.title} fill className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-44 items-center justify-center bg-surface-hover">
|
||||
<span className="font-mono text-3xl text-accent opacity-20">//</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
|
||||
{product.productType === "node-product" ? t("node_product") : t("digital")}
|
||||
</Badge>
|
||||
{product.featured && (
|
||||
<span className="font-mono text-xs text-accent">★</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
|
||||
|
||||
{product.description && (
|
||||
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
|
||||
{product.description}
|
||||
</p>
|
||||
return (
|
||||
<AnimatedSection key={product.id} delay={i * 0.07}>
|
||||
<div className="flex h-full flex-col overflow-hidden rounded-xl border border-border bg-surface transition-all hover:border-accent/20 hover:bg-surface-hover">
|
||||
{/* Image */}
|
||||
{product.coverImage ? (
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<Image src={product.coverImage} alt={product.title} fill className="object-cover" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-44 items-center justify-center bg-surface-hover">
|
||||
<span className="font-mono text-3xl text-accent opacity-20">//</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3">
|
||||
{product.price !== undefined && (
|
||||
<span className="font-mono text-sm font-semibold text-accent">
|
||||
{formatPrice(product.price, product.currency ?? "IRR")}
|
||||
</span>
|
||||
)}
|
||||
{product.purchaseUrl ? (
|
||||
<Button asChild size="sm" className="ms-auto">
|
||||
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={13} />
|
||||
{t("buy")}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="ms-auto">{t("buy")}</Button>
|
||||
<div className="flex flex-1 flex-col p-5">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant={product.productType === "node-product" ? "default" : "secondary"}>
|
||||
{product.productType === "node-product" ? t("node_product") : t("digital")}
|
||||
</Badge>
|
||||
{product.featured && <span className="font-mono text-xs text-accent">★</span>}
|
||||
</div>
|
||||
|
||||
<h3 className="mb-2 font-semibold text-text-primary">{product.title}</h3>
|
||||
|
||||
{product.description && (
|
||||
<p className="mb-4 flex-1 text-sm text-text-secondary leading-relaxed line-clamp-3">
|
||||
{product.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center justify-between gap-3">
|
||||
{priceStr && (
|
||||
<span className="font-mono text-sm font-semibold text-accent">{priceStr}</span>
|
||||
)}
|
||||
{product.purchaseUrl ? (
|
||||
<Button asChild size="sm" className="ms-auto">
|
||||
<a href={product.purchaseUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={13} />
|
||||
{t("buy")}
|
||||
</a>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" className="ms-auto">{t("buy")}</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<UsesItem[]>(allUsesItemsQuery);
|
||||
const items = sanityItems && sanityItems.length > 0 ? sanityItems : fallbackItems;
|
||||
const items = await getAllUsesItems();
|
||||
|
||||
const grouped = categoryOrder.reduce<Record<string, UsesItem[]>>((acc, cat) => {
|
||||
const grouped = categoryOrder.reduce<Record<string, typeof items>>((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) {
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{catItems.map((item) => (
|
||||
<div
|
||||
key={item._id}
|
||||
key={item.id}
|
||||
className="group flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4 transition-all hover:border-accent/20"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -98,6 +77,9 @@ export default async function UsesPage({ params }: Props) {
|
||||
</div>
|
||||
</AnimatedSection>
|
||||
))}
|
||||
{Object.keys(grouped).length === 0 && (
|
||||
<p className="text-center text-text-secondary py-10">هنوز ابزاری اضافه نشده.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<Metadata> {
|
||||
const { slug } = await params;
|
||||
const project = await sanityFetch<Project>(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<Project>(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 (
|
||||
<article className="relative z-10 pt-24">
|
||||
@@ -60,10 +56,10 @@ export default async function ProjectPage({ params }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Cover */}
|
||||
{coverUrl && (
|
||||
{project.coverImage && (
|
||||
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
|
||||
<Image src={coverUrl} alt={project.coverImage?.alt ?? project.title} fill className="object-cover" priority />
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background/80" />
|
||||
<Image src={project.coverImage} alt={project.title} fill className="object-cover" priority />
|
||||
<div className="absolute inset-0 bg-linear-to-b from-transparent to-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -96,23 +92,23 @@ export default async function ProjectPage({ params }: Props) {
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
{project.body && <PortableTextRenderer value={project.body as unknown[]} />}
|
||||
{project.body && <TiptapRenderer html={project.body} />}
|
||||
|
||||
{/* Tech stack + tools */}
|
||||
<div className="mt-10 grid gap-6 border-t border-border pt-8 sm:grid-cols-2">
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
{project.techStack.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-xs text-accent">// {tWork("tech_stack")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{project.techStack.map((t) => (
|
||||
<span key={t} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
|
||||
{t}
|
||||
{project.techStack.map((tech) => (
|
||||
<span key={tech} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
|
||||
{tech}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{project.toolsUsed && project.toolsUsed.length > 0 && (
|
||||
{project.toolsUsed.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 font-mono text-xs text-accent">// {tWork("tools")}</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -127,18 +123,13 @@ export default async function ProjectPage({ params }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Gallery */}
|
||||
{project.gallery && project.gallery.length > 0 && (
|
||||
{project.gallery.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h3 className="mb-4 font-mono text-xs text-accent">// gallery</h3>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{project.gallery.map((img, i) => (
|
||||
{project.gallery.map((imgUrl, i) => (
|
||||
<div key={i} className="relative aspect-video overflow-hidden rounded-lg border border-border">
|
||||
<Image
|
||||
src={urlFor(img).width(800).url()}
|
||||
alt={img.alt ?? `Gallery ${i + 1}`}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
<Image src={imgUrl} alt={`Gallery ${i + 1}`} fill className="object-cover" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Project[]>(allProjectsQuery, { locale });
|
||||
const projects = await getAllProjects(locale);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
@@ -29,7 +28,7 @@ export default async function WorkPage({ params }: Props) {
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</div>
|
||||
<WorkListClient projects={projects ?? []} />
|
||||
<WorkListClient projects={projects} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Metadata> {
|
||||
const { slug } = await params;
|
||||
const post = await sanityFetch<Post>(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<Post>(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<Post[]>(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 (
|
||||
<>
|
||||
<ReadingProgress />
|
||||
@@ -107,39 +98,34 @@ export default async function PostPage({ params }: Props) {
|
||||
<p className="text-lg text-text-secondary leading-relaxed">{post.excerpt}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-4">
|
||||
{post.author && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||
<span>{post.author.name}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6 flex items-center justify-end gap-4">
|
||||
<ShareButtons title={post.title} url={postUrl} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Cover image */}
|
||||
{coverUrl && (
|
||||
{post.coverImage && (
|
||||
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
|
||||
<Image
|
||||
src={coverUrl}
|
||||
alt={post.coverImage?.alt ?? post.title}
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-transparent to-background/80" />
|
||||
<div className="absolute inset-0 bg-linear-to-b from-transparent to-background/80" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
<div className="mx-auto max-w-3xl px-6 pb-16">
|
||||
{post.body && <PortableTextRenderer value={post.body as unknown[]} />}
|
||||
{post.body && <TiptapRenderer html={post.body} />}
|
||||
|
||||
{/* Tags */}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-10 flex flex-wrap gap-2 border-t border-border pt-6">
|
||||
{post.tags.map((tag) => (
|
||||
<Badge key={tag._id} variant="secondary">{tag.title}</Badge>
|
||||
{tags.map((tag) => (
|
||||
<Badge key={tag.id} variant="secondary">{tag.title}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -151,13 +137,13 @@ export default async function PostPage({ params }: Props) {
|
||||
</div>
|
||||
|
||||
{/* Related posts */}
|
||||
{related && related.length > 0 && (
|
||||
{flatRelated.length > 0 && (
|
||||
<section className="border-t border-border bg-surface/30 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<h2 className="mb-8 font-mono text-xs text-accent">// {tCommon("related_posts")}</h2>
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{related.map((p) => (
|
||||
<PostCard key={p._id} post={p} categoryLabel={tCat(p.category)} />
|
||||
{flatRelated.map((p) => (
|
||||
<PostCard key={p.id} post={p} categoryLabel={tCat(p.category)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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<Metadata> {
|
||||
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<Post[]>(allPostsQuery, { locale });
|
||||
// Flatten tags for the component
|
||||
const flatPosts = posts.map((p) => ({
|
||||
...p,
|
||||
tags: p.tags.map((pt) => pt.tag),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
|
||||
@@ -32,7 +34,7 @@ export default async function WritingPage({ params }: Props) {
|
||||
<h1 className="text-4xl font-bold text-text-primary md:text-5xl">{t("title")}</h1>
|
||||
<p className="mt-4 max-w-2xl text-text-secondary">{t("subtitle")}</p>
|
||||
</div>
|
||||
<WritingListClient posts={posts ?? []} />
|
||||
<WritingListClient posts={flatPosts} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/app/admin/layout.tsx
Normal file
44
src/app/admin/layout.tsx
Normal file
@@ -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 (
|
||||
<div dir="rtl" className="min-h-screen bg-background font-persian">
|
||||
{session.isLoggedIn ? (
|
||||
<AuthedLayout username={session.username}>{children}</AuthedLayout>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function AuthedLayout({
|
||||
children,
|
||||
username,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
username?: string;
|
||||
}) {
|
||||
const unreadCount = await prisma.contactMessage.count({ where: { isRead: false } });
|
||||
|
||||
return (
|
||||
<>
|
||||
<AdminSidebar username={username} unreadCount={unreadCount} />
|
||||
<main className="lg:pr-64 min-h-screen">
|
||||
<div className="p-6 lg:p-8">{children}</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
100
src/app/admin/login/page.tsx
Normal file
100
src/app/admin/login/page.tsx
Normal file
@@ -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<string | null>(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 (
|
||||
<div className="flex min-h-screen items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl border border-accent/30 bg-accent/10">
|
||||
<Lock size={24} className="text-accent" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">پنل مدیریت</h1>
|
||||
<p className="mt-1 text-sm text-text-secondary">biztaghavi.com</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="rounded-2xl border border-border bg-surface p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-primary">نام کاربری</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-text-primary">رمز عبور</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPass ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPass(!showPass)}
|
||||
className="absolute inset-y-0 end-3 flex items-center text-text-secondary hover:text-text-primary"
|
||||
>
|
||||
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-background transition-colors hover:bg-accent-hover disabled:opacity-60"
|
||||
>
|
||||
{loading ? <Loader2 size={16} className="animate-spin" /> : "ورود"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
src/app/admin/messages/page.tsx
Normal file
124
src/app/admin/messages/page.tsx
Normal file
@@ -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<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selected, setSelected] = useState<Message | null>(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 (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-text-primary">پیامها</h1>
|
||||
{unread > 0 && <p className="text-sm text-accent mt-1">{unread} پیام خواندهنشده</p>}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<Mail size={32} className="text-text-secondary mb-3" />
|
||||
<p className="text-text-secondary">هنوز پیامی دریافت نشده</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_1.4fr]">
|
||||
{/* List */}
|
||||
<div className="space-y-2">
|
||||
{messages.map((msg) => (
|
||||
<button
|
||||
key={msg.id}
|
||||
onClick={() => handleSelect(msg)}
|
||||
className={`w-full rounded-xl border p-4 text-right transition-all hover:border-accent/30 ${selected?.id === msg.id ? "border-accent/40 bg-accent/5" : "border-border bg-surface"}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Circle size={8} className={`mt-2 shrink-0 ${!msg.isRead ? "fill-accent text-accent" : "fill-border text-border"}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className={`font-medium ${!msg.isRead ? "text-text-primary" : "text-text-secondary"}`}>{msg.name}</span>
|
||||
<span className="font-mono text-xs text-text-secondary shrink-0">{formatDate(msg.createdAt, "fa")}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm text-text-secondary truncate">{msg.subject ?? msg.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail */}
|
||||
{selected ? (
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4 h-fit">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-text-primary">{selected.subject ?? "(بدون موضوع)"}</h2>
|
||||
<div className="mt-1 flex flex-wrap gap-3 text-sm text-text-secondary">
|
||||
<span>{selected.name}</span>
|
||||
<a href={`mailto:${selected.email}`} className="text-accent hover:underline">{selected.email}</a>
|
||||
</div>
|
||||
<div className="mt-1 font-mono text-xs text-text-secondary">{formatDate(selected.createdAt, "fa")}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => markRead(selected.id, !selected.isRead)} title={selected.isRead ? "علامتگذاری به عنوان خواندهنشده" : "علامتگذاری به عنوان خواندهشده"} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
|
||||
{selected.isRead ? <Mail size={14} /> : <MailOpen size={14} />}
|
||||
</button>
|
||||
<button onClick={() => handleDelete(selected.id)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="text-text-primary leading-relaxed whitespace-pre-wrap">{selected.message}</p>
|
||||
</div>
|
||||
<a href={`mailto:${selected.email}?subject=Re: ${selected.subject ?? ""}`} className="inline-flex items-center gap-2 rounded-lg bg-accent/10 border border-accent/20 px-4 py-2 text-sm text-accent hover:bg-accent/20 transition-colors">
|
||||
<Mail size={14} /> پاسخ دادن
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="hidden lg:flex items-center justify-center rounded-xl border border-dashed border-border h-64">
|
||||
<p className="text-sm text-text-secondary">یک پیام را انتخاب کنید</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
132
src/app/admin/page.tsx
Normal file
132
src/app/admin/page.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-text-primary">داشبورد</h1>
|
||||
<p className="text-text-secondary mt-1">خوش آمدی، {session.username} 👋</p>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid gap-4 grid-cols-2 lg:grid-cols-3 mb-10">
|
||||
{stats.map((stat) => (
|
||||
<a
|
||||
key={stat.href}
|
||||
href={stat.href}
|
||||
className="group rounded-xl border border-border bg-surface p-5 transition-all hover:border-accent/30 hover:bg-surface-hover"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-text-primary">{stat.value}</div>
|
||||
<div className="mt-1 text-sm text-text-secondary">{stat.label}</div>
|
||||
</div>
|
||||
<div className={`mt-1 ${stat.color}`}>
|
||||
<stat.icon size={22} />
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
{/* Recent posts */}
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-text-primary">آخرین نوشتهها</h2>
|
||||
<a href="/admin/posts/new" className="text-xs text-accent hover:text-accent-hover">+ جدید</a>
|
||||
</div>
|
||||
{recentPosts.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary py-4 text-center">هنوز نوشتهای ندارید</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentPosts.map((post) => (
|
||||
<a
|
||||
key={post.id}
|
||||
href={`/admin/posts/${post.id}`}
|
||||
className="flex items-start justify-between gap-3 rounded-lg p-2 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary line-clamp-1">{post.title}</div>
|
||||
<div className="mt-0.5 font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, "fa")} · {post.locale}
|
||||
</div>
|
||||
</div>
|
||||
{post.featured && (
|
||||
<span className="shrink-0 text-xs text-accent">★</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recent messages */}
|
||||
<div className="rounded-xl border border-border bg-surface p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-text-primary">پیامهای اخیر</h2>
|
||||
<a href="/admin/messages" className="text-xs text-accent hover:text-accent-hover">مشاهده همه</a>
|
||||
</div>
|
||||
{recentMessages.length === 0 ? (
|
||||
<p className="text-sm text-text-secondary py-4 text-center">هنوز پیامی دریافت نشده</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentMessages.map((msg) => (
|
||||
<a
|
||||
key={msg.id}
|
||||
href="/admin/messages"
|
||||
className="flex items-start gap-3 rounded-lg p-2 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<div className={`mt-1 h-2 w-2 shrink-0 rounded-full ${!msg.isRead ? "bg-accent" : "bg-border"}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-text-primary">{msg.name}</div>
|
||||
<div className="text-xs text-text-secondary line-clamp-1">{msg.subject ?? msg.message}</div>
|
||||
</div>
|
||||
<div className="shrink-0 font-mono text-xs text-text-secondary">
|
||||
{formatDate(msg.createdAt, "fa")}
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
src/app/admin/posts/PostForm.tsx
Normal file
262
src/app/admin/posts/PostForm.tsx
Normal file
@@ -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<PostFormData>;
|
||||
}
|
||||
|
||||
export function PostForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const [form, setForm] = useState<PostFormData>({
|
||||
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 (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>
|
||||
)}
|
||||
{success && (
|
||||
<div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد ✓</div>
|
||||
)}
|
||||
|
||||
{/* Basic info */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات اصلی</h2>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
required
|
||||
className="admin-input w-full"
|
||||
placeholder="عنوان نوشته را وارد کنید..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.slug}
|
||||
onChange={(e) => set("slug", e.target.value)}
|
||||
required
|
||||
dir="ltr"
|
||||
className="admin-input w-full font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
|
||||
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
|
||||
<option value="fa">فارسی</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی *</label>
|
||||
<select value={form.category} onChange={(e) => set("category", e.target.value)} className="admin-input w-full">
|
||||
{CATEGORIES.map((c) => (
|
||||
<option key={c.value} value={c.value}>{c.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">تاریخ انتشار</label>
|
||||
<input
|
||||
type="date"
|
||||
value={form.publishedAt}
|
||||
onChange={(e) => set("publishedAt", e.target.value)}
|
||||
dir="ltr"
|
||||
className="admin-input w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">چکیده</label>
|
||||
<textarea
|
||||
value={form.excerpt}
|
||||
onChange={(e) => set("excerpt", e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="خلاصه کوتاه از نوشته..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="featured"
|
||||
checked={form.featured}
|
||||
onChange={(e) => set("featured", e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border accent-accent"
|
||||
/>
|
||||
<label htmlFor="featured" className="text-sm text-text-primary">نوشته ویژه (Featured)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cover image */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر کاور</h2>
|
||||
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تگها</h2>
|
||||
<TagsInput value={form.tagIds} onChange={(ids) => set("tagIds", ids)} />
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">محتوا</h2>
|
||||
<RichTextEditor
|
||||
value={form.body}
|
||||
onChange={(html) => set("body", html)}
|
||||
placeholder="محتوای نوشته را اینجا بنویسید..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SEO */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">SEO (اختیاری)</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان SEO</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.seoTitle}
|
||||
onChange={(e) => set("seoTitle", e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="پیشفرض: عنوان نوشته"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات SEO</label>
|
||||
<textarea
|
||||
value={form.seoDesc}
|
||||
onChange={(e) => set("seoDesc", e.target.value)}
|
||||
rows={2}
|
||||
className="admin-input w-full resize-none"
|
||||
placeholder="پیشفرض: چکیده نوشته"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-3 justify-end sticky bottom-6">
|
||||
<a
|
||||
href={`/${form.locale}/writing/${form.slug}`}
|
||||
target="_blank"
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<Eye size={14} />
|
||||
پیشنمایش
|
||||
</a>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors"
|
||||
>
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{initialData?.id ? "ذخیره تغییرات" : "انتشار نوشته"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
47
src/app/admin/posts/[id]/page.tsx
Normal file
47
src/app/admin/posts/[id]/page.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { PostForm } from "../PostForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
||||
export default async function EditPostPage({ params }: Props) {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
|
||||
const { id } = await params;
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
if (!post) notFound();
|
||||
|
||||
const initialData = {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
slug: post.slug,
|
||||
locale: post.locale,
|
||||
category: post.category,
|
||||
excerpt: post.excerpt ?? "",
|
||||
body: post.body ?? "",
|
||||
coverImage: post.coverImage ?? "",
|
||||
featured: post.featured,
|
||||
publishedAt: post.publishedAt.toISOString().split("T")[0],
|
||||
seoTitle: post.seoTitle ?? "",
|
||||
seoDesc: post.seoDesc ?? "",
|
||||
tagIds: post.tags.map((pt) => pt.tag.id),
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/posts" className="text-sm text-text-secondary hover:text-accent">← برگشت به نوشتهها</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش نوشته</h1>
|
||||
<p className="text-sm text-text-secondary font-mono mt-1">/{post.slug}</p>
|
||||
</div>
|
||||
<PostForm initialData={initialData} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
src/app/admin/posts/new/page.tsx
Normal file
18
src/app/admin/posts/new/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { PostForm } from "../PostForm";
|
||||
|
||||
export default async function NewPostPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/posts" className="text-sm text-text-secondary hover:text-accent">← برگشت به نوشتهها</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">نوشته جدید</h1>
|
||||
</div>
|
||||
<PostForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
129
src/app/admin/posts/page.tsx
Normal file
129
src/app/admin/posts/page.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/types";
|
||||
import { Plus, Pencil, Trash2, Star } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPostsPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
|
||||
const posts = await prisma.post.findMany({
|
||||
orderBy: { publishedAt: "desc" },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
"founder-notes": "text-blue-400 bg-blue-400/10 border-blue-400/20",
|
||||
"marketing-branding": "text-purple-400 bg-purple-400/10 border-purple-400/20",
|
||||
"product-thinking": "text-green-400 bg-green-400/10 border-green-400/20",
|
||||
"tech-builds": "text-orange-400 bg-orange-400/10 border-orange-400/20",
|
||||
"business-experiments": "text-pink-400 bg-pink-400/10 border-pink-400/20",
|
||||
"systems-productivity": "text-yellow-400 bg-yellow-400/10 border-yellow-400/20",
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">نوشتهها</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{posts.length} نوشته</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/posts/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
نوشته جدید
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<p className="text-text-secondary">هنوز نوشتهای ندارید</p>
|
||||
<a href="/admin/posts/new" className="mt-4 text-sm text-accent hover:text-accent-hover">
|
||||
اولین نوشته را بنویسید →
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border bg-surface overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-hover text-right">
|
||||
<th className="px-4 py-3 font-medium text-text-secondary">عنوان</th>
|
||||
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">دستهبندی</th>
|
||||
<th className="hidden px-4 py-3 font-medium text-text-secondary lg:table-cell">زبان</th>
|
||||
<th className="hidden px-4 py-3 font-medium text-text-secondary lg:table-cell">تاریخ</th>
|
||||
<th className="px-4 py-3 font-medium text-text-secondary">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{posts.map((post) => (
|
||||
<tr key={post.id} className="border-b border-border/50 hover:bg-surface-hover transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{post.featured && <Star size={12} className="text-accent shrink-0" />}
|
||||
<span className="font-medium text-text-primary line-clamp-1">{post.title}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs text-text-secondary font-mono">/{post.slug}</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
<span className={`rounded-full border px-2 py-0.5 text-xs ${categoryColors[post.category] ?? "text-text-secondary bg-surface-hover border-border"}`}>
|
||||
{post.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 lg:table-cell">
|
||||
<span className="font-mono text-xs text-text-secondary">{post.locale}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 lg:table-cell">
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, "fa")}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={`/admin/posts/${post.id}`}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</a>
|
||||
<DeletePostButton id={post.id} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePostButton({ id }: { id: string }) {
|
||||
return (
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await fetch(`/api/admin/posts/${id}`, { method: "DELETE" });
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("آیا مطمئن هستید؟")) return;
|
||||
const res = await fetch(`/api/admin/posts/${id}`, { method: "DELETE" });
|
||||
if (res.ok) window.location.reload();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
128
src/app/admin/products/ProductForm.tsx
Normal file
128
src/app/admin/products/ProductForm.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
import { ImageUpload } from "@/components/admin/ImageUpload";
|
||||
import { slugify } from "@/lib/types";
|
||||
|
||||
interface ProductFormData {
|
||||
id?: string;
|
||||
title: string; slug: string; locale: string;
|
||||
description: string; price: string; currency: string;
|
||||
coverImage: string; purchaseUrl: string;
|
||||
productType: string; featured: boolean; sortOrder: number;
|
||||
seoTitle: string; seoDesc: string;
|
||||
}
|
||||
|
||||
interface Props { initialData?: Partial<ProductFormData> }
|
||||
|
||||
export function ProductForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [form, setForm] = useState<ProductFormData>({
|
||||
title: "", slug: "", locale: "fa", description: "",
|
||||
price: "", currency: "IRR", coverImage: "", purchaseUrl: "",
|
||||
productType: "digital", featured: false, sortOrder: 0,
|
||||
seoTitle: "", seoDesc: "", ...initialData,
|
||||
});
|
||||
function set(key: keyof ProductFormData, value: unknown) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.title || !form.slug) { setError("عنوان و اسلاگ الزامی است"); return; }
|
||||
setError(null); setSaving(true);
|
||||
try {
|
||||
const method = initialData?.id ? "PUT" : "POST";
|
||||
const url = initialData?.id ? `/api/admin/products/${initialData.id}` : "/api/admin/products";
|
||||
const res = await fetch(url, {
|
||||
method, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
price: form.price !== "" ? parseFloat(form.price) : null,
|
||||
sortOrder: parseInt(String(form.sortOrder)) || 0,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/admin/products"), 800);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "خطا"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد ✓</div>}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات محصول</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
|
||||
<input type="text" value={form.title} onChange={(e) => { set("title", e.target.value); if (!initialData?.id) set("slug", slugify(e.target.value)); }} required className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => set("slug", e.target.value)} required dir="ltr" className="admin-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
|
||||
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
|
||||
<option value="fa">فارسی</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">نوع محصول</label>
|
||||
<select value={form.productType} onChange={(e) => set("productType", e.target.value)} className="admin-input w-full">
|
||||
<option value="digital">دیجیتال</option>
|
||||
<option value="node-product">NODE Product</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">قیمت</label>
|
||||
<input type="number" min="0" step="0.01" value={form.price} onChange={(e) => set("price", e.target.value)} className="admin-input w-full" placeholder="0 = رایگان" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">ارز</label>
|
||||
<select value={form.currency} onChange={(e) => set("currency", e.target.value)} className="admin-input w-full">
|
||||
<option value="IRR">تومان</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک خرید</label>
|
||||
<input type="url" value={form.purchaseUrl} onChange={(e) => set("purchaseUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://..." />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
|
||||
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={3} className="admin-input w-full resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="featured-product" checked={form.featured} onChange={(e) => set("featured", e.target.checked)} className="h-4 w-4 rounded border-border accent-accent" />
|
||||
<label htmlFor="featured-product" className="text-sm text-text-primary">محصول ویژه</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر</h2>
|
||||
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{initialData?.id ? "ذخیره تغییرات" : "ایجاد محصول"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
32
src/app/admin/products/[id]/page.tsx
Normal file
32
src/app/admin/products/[id]/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ProductForm } from "../ProductForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
||||
export default async function EditProductPage({ params }: Props) {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
const { id } = await params;
|
||||
const product = await prisma.product.findUnique({ where: { id } });
|
||||
if (!product) notFound();
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/products" className="text-sm text-text-secondary hover:text-accent">← برگشت به محصولات</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش محصول</h1>
|
||||
</div>
|
||||
<ProductForm initialData={{
|
||||
id: product.id, title: product.title, slug: product.slug,
|
||||
locale: product.locale, description: product.description ?? "",
|
||||
price: product.price !== null ? String(product.price) : "",
|
||||
currency: product.currency, coverImage: product.coverImage ?? "",
|
||||
purchaseUrl: product.purchaseUrl ?? "", productType: product.productType,
|
||||
featured: product.featured, sortOrder: product.sortOrder,
|
||||
seoTitle: product.seoTitle ?? "", seoDesc: product.seoDesc ?? "",
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/app/admin/products/new/page.tsx
Normal file
17
src/app/admin/products/new/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { ProductForm } from "../ProductForm";
|
||||
|
||||
export default async function NewProductPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/products" className="text-sm text-text-secondary hover:text-accent">← برگشت به محصولات</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">محصول جدید</h1>
|
||||
</div>
|
||||
<ProductForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
src/app/admin/products/page.tsx
Normal file
81
src/app/admin/products/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Plus, Pencil, Trash2, Star, ExternalLink } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminProductsPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
const products = await prisma.product.findMany({ orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }] });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">محصولات</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{products.length} محصول</p>
|
||||
</div>
|
||||
<a href="/admin/products/new" className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
|
||||
<Plus size={16} /> محصول جدید
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{products.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<p className="text-text-secondary">هنوز محصولی ندارید</p>
|
||||
<a href="/admin/products/new" className="mt-4 text-sm text-accent">اولین محصول را اضافه کنید →</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl border border-border bg-surface overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-hover text-right">
|
||||
<th className="px-4 py-3 font-medium text-text-secondary">عنوان</th>
|
||||
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">نوع</th>
|
||||
<th className="hidden px-4 py-3 font-medium text-text-secondary md:table-cell">قیمت</th>
|
||||
<th className="px-4 py-3 font-medium text-text-secondary">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{products.map((p) => (
|
||||
<tr key={p.id} className="border-b border-border/50 hover:bg-surface-hover transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{p.featured && <Star size={12} className="text-accent shrink-0" />}
|
||||
<span className="font-medium text-text-primary">{p.title}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
<span className="font-mono text-xs text-text-secondary">{p.productType}</span>
|
||||
</td>
|
||||
<td className="hidden px-4 py-3 md:table-cell">
|
||||
<span className="font-mono text-xs text-accent">
|
||||
{p.price !== null && p.price !== undefined ? (p.price === 0 ? "رایگان" : `${p.price} ${p.currency}`) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<a href={`/admin/products/${p.id}`} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
|
||||
<Pencil size={14} />
|
||||
</a>
|
||||
{p.purchaseUrl && (
|
||||
<a href={p.purchaseUrl} target="_blank" rel="noopener noreferrer" className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors">
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
<button type="button" onClick={async () => { if (!confirm("حذف شود؟")) return; await fetch(`/api/admin/products/${p.id}`, { method: "DELETE" }); window.location.reload(); }} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors">
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
199
src/app/admin/projects/ProjectForm.tsx
Normal file
199
src/app/admin/projects/ProjectForm.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Loader2, Save, Plus, X } from "lucide-react";
|
||||
import { RichTextEditor } from "@/components/admin/RichTextEditor";
|
||||
import { ImageUpload } from "@/components/admin/ImageUpload";
|
||||
import { slugify } from "@/lib/types";
|
||||
|
||||
const PROJECT_TYPES = [
|
||||
{ value: "product", label: "محصول" },
|
||||
{ value: "brand-system", label: "سیستم برند" },
|
||||
{ value: "open-source", label: "اوپن سورس" },
|
||||
{ value: "creative-work", label: "کار خلاقانه" },
|
||||
];
|
||||
|
||||
interface ProjectFormData {
|
||||
id?: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
locale: string;
|
||||
projectType: string;
|
||||
description: string;
|
||||
body: string;
|
||||
coverImage: string;
|
||||
gallery: string[];
|
||||
techStack: string[];
|
||||
toolsUsed: string[];
|
||||
liveUrl: string;
|
||||
githubUrl: string;
|
||||
featured: boolean;
|
||||
sortOrder: number;
|
||||
seoTitle: string;
|
||||
seoDesc: string;
|
||||
}
|
||||
|
||||
interface Props { initialData?: Partial<ProjectFormData> }
|
||||
|
||||
export function ProjectForm({ initialData }: Props) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [newTech, setNewTech] = useState("");
|
||||
const [newTool, setNewTool] = useState("");
|
||||
|
||||
const [form, setForm] = useState<ProjectFormData>({
|
||||
title: "", slug: "", locale: "fa", projectType: "product",
|
||||
description: "", body: "", coverImage: "", gallery: [],
|
||||
techStack: [], toolsUsed: [], liveUrl: "", githubUrl: "",
|
||||
featured: false, sortOrder: 0, seoTitle: "", seoDesc: "",
|
||||
...initialData,
|
||||
});
|
||||
|
||||
function set(key: keyof ProjectFormData, value: unknown) {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form.title || !form.slug) { setError("عنوان و اسلاگ الزامی است"); return; }
|
||||
setError(null); setSaving(true);
|
||||
try {
|
||||
const method = initialData?.id ? "PUT" : "POST";
|
||||
const url = initialData?.id ? `/api/admin/projects/${initialData.id}` : "/api/admin/projects";
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
gallery: JSON.stringify(form.gallery),
|
||||
techStack: JSON.stringify(form.techStack),
|
||||
toolsUsed: JSON.stringify(form.toolsUsed),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
|
||||
setSuccess(true);
|
||||
setTimeout(() => router.push("/admin/projects"), 800);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "خطا در ذخیره"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
function addToArray(key: "techStack" | "toolsUsed", val: string) {
|
||||
if (val.trim() && !form[key].includes(val.trim())) {
|
||||
set(key, [...form[key], val.trim()]);
|
||||
}
|
||||
}
|
||||
function removeFromArray(key: "techStack" | "toolsUsed", val: string) {
|
||||
set(key, form[key].filter((v) => v !== val));
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">ذخیره شد ✓</div>}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات اصلی</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان *</label>
|
||||
<input type="text" value={form.title} onChange={(e) => { set("title", e.target.value); if (!initialData?.id) set("slug", slugify(e.target.value)); }} required className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">اسلاگ *</label>
|
||||
<input type="text" value={form.slug} onChange={(e) => set("slug", e.target.value)} required dir="ltr" className="admin-input w-full font-mono text-sm" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">زبان</label>
|
||||
<select value={form.locale} onChange={(e) => set("locale", e.target.value)} className="admin-input w-full">
|
||||
<option value="fa">فارسی</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">نوع پروژه</label>
|
||||
<select value={form.projectType} onChange={(e) => set("projectType", e.target.value)} className="admin-input w-full">
|
||||
{PROJECT_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">ترتیب نمایش</label>
|
||||
<input type="number" value={form.sortOrder} onChange={(e) => set("sortOrder", parseInt(e.target.value) || 0)} className="admin-input w-full" />
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیح کوتاه</label>
|
||||
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={2} className="admin-input w-full resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک زنده</label>
|
||||
<input type="url" value={form.liveUrl} onChange={(e) => set("liveUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک GitHub</label>
|
||||
<input type="url" value={form.githubUrl} onChange={(e) => set("githubUrl", e.target.value)} dir="ltr" className="admin-input w-full" placeholder="https://github.com/..." />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<input type="checkbox" id="featured-project" checked={form.featured} onChange={(e) => set("featured", e.target.checked)} className="h-4 w-4 rounded border-border accent-accent" />
|
||||
<label htmlFor="featured-project" className="text-sm text-text-primary">پروژه ویژه (Featured)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تصویر کاور</h2>
|
||||
<ImageUpload value={form.coverImage} onChange={(url) => set("coverImage", url)} />
|
||||
</div>
|
||||
|
||||
{/* Tech stack */}
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">تکنولوژیها و ابزارها</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">تکنولوژیها</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{form.techStack.map((t) => (
|
||||
<span key={t} className="flex items-center gap-1 rounded-full border border-border bg-surface-hover px-2.5 py-1 text-xs text-text-secondary">
|
||||
{t} <button type="button" onClick={() => removeFromArray("techStack", t)}><X size={10} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newTech} onChange={(e) => setNewTech(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addToArray("techStack", newTech); setNewTech(""); } }} placeholder="مثلاً: Next.js + Enter" className="admin-input flex-1" dir="ltr" />
|
||||
<button type="button" onClick={() => { addToArray("techStack", newTech); setNewTech(""); }} className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">ابزارها</label>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
{form.toolsUsed.map((t) => (
|
||||
<span key={t} className="flex items-center gap-1 rounded-full border border-border bg-surface-hover px-2.5 py-1 text-xs text-text-secondary">
|
||||
{t} <button type="button" onClick={() => removeFromArray("toolsUsed", t)}><X size={10} /></button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input type="text" value={newTool} onChange={(e) => setNewTool(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); addToArray("toolsUsed", newTool); setNewTool(""); } }} placeholder="مثلاً: Figma + Enter" className="admin-input flex-1" dir="ltr" />
|
||||
<button type="button" onClick={() => { addToArray("toolsUsed", newTool); setNewTool(""); }} className="flex items-center gap-1 rounded-lg border border-border px-3 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors">
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-3">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">محتوا</h2>
|
||||
<RichTextEditor value={form.body} onChange={(html) => set("body", html)} placeholder="توضیحات کامل پروژه..." />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 justify-end">
|
||||
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
{initialData?.id ? "ذخیره تغییرات" : "ایجاد پروژه"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
39
src/app/admin/projects/[id]/page.tsx
Normal file
39
src/app/admin/projects/[id]/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ProjectForm } from "../ProjectForm";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
interface Props { params: Promise<{ id: string }> }
|
||||
|
||||
export default async function EditProjectPage({ params }: Props) {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({ where: { id } });
|
||||
if (!project) notFound();
|
||||
|
||||
function parseArr(val: string | null): string[] {
|
||||
if (!val) return [];
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/projects" className="text-sm text-text-secondary hover:text-accent">← برگشت به پروژهها</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">ویرایش پروژه</h1>
|
||||
</div>
|
||||
<ProjectForm initialData={{
|
||||
id: project.id, title: project.title, slug: project.slug,
|
||||
locale: project.locale, projectType: project.projectType,
|
||||
description: project.description ?? "", body: project.body ?? "",
|
||||
coverImage: project.coverImage ?? "", gallery: parseArr(project.gallery),
|
||||
techStack: parseArr(project.techStack), toolsUsed: parseArr(project.toolsUsed),
|
||||
liveUrl: project.liveUrl ?? "", githubUrl: project.githubUrl ?? "",
|
||||
featured: project.featured, sortOrder: project.sortOrder,
|
||||
seoTitle: project.seoTitle ?? "", seoDesc: project.seoDesc ?? "",
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
17
src/app/admin/projects/new/page.tsx
Normal file
17
src/app/admin/projects/new/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { ProjectForm } from "../ProjectForm";
|
||||
|
||||
export default async function NewProjectPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<a href="/admin/projects" className="text-sm text-text-secondary hover:text-accent">← برگشت به پروژهها</a>
|
||||
<h1 className="mt-2 text-2xl font-bold text-text-primary">پروژه جدید</h1>
|
||||
</div>
|
||||
<ProjectForm />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
src/app/admin/projects/page.tsx
Normal file
96
src/app/admin/projects/page.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Plus, Pencil, Trash2, Star, ExternalLink } from "lucide-react";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminProjectsPage() {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) redirect("/admin/login");
|
||||
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">پروژهها</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{projects.length} پروژه</p>
|
||||
</div>
|
||||
<a
|
||||
href="/admin/projects/new"
|
||||
className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors"
|
||||
>
|
||||
<Plus size={16} />
|
||||
پروژه جدید
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{projects.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<p className="text-text-secondary">هنوز پروژهای ندارید</p>
|
||||
<a href="/admin/projects/new" className="mt-4 text-sm text-accent hover:text-accent-hover">اولین پروژه را اضافه کنید →</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<div key={project.id} className="rounded-xl border border-border bg-surface p-4 space-y-3">
|
||||
{project.coverImage && (
|
||||
<div className="relative h-32 overflow-hidden rounded-lg bg-surface-hover">
|
||||
<img src={project.coverImage} alt={project.title} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="font-semibold text-text-primary line-clamp-1">{project.title}</h3>
|
||||
{project.featured && <Star size={12} className="text-accent shrink-0 mt-1" />}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="rounded-full bg-surface-hover border border-border px-2 py-0.5 font-mono text-xs text-text-secondary">
|
||||
{project.projectType}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-text-secondary">{project.locale}</span>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="mt-2 text-xs text-text-secondary line-clamp-2">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<a
|
||||
href={`/admin/projects/${project.id}`}
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-lg border border-border py-1.5 text-xs text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
|
||||
>
|
||||
<Pencil size={12} /> ویرایش
|
||||
</a>
|
||||
{project.liveUrl && (
|
||||
<a
|
||||
href={project.liveUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
if (!confirm("حذف شود؟")) return;
|
||||
await fetch(`/api/admin/projects/${project.id}`, { method: "DELETE" });
|
||||
window.location.reload();
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
src/app/admin/settings/page.tsx
Normal file
113
src/app/admin/settings/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Loader2, Save } from "lucide-react";
|
||||
|
||||
interface Settings {
|
||||
siteTitle: string; description: string; currentStatus: string;
|
||||
telegramChannel: string; socialGithub: string; socialLinkedin: string;
|
||||
socialTwitter: string; socialTelegram: string; socialInstagram: string;
|
||||
}
|
||||
|
||||
const empty: Settings = {
|
||||
siteTitle: "Ali Taghavi", description: "", currentStatus: "",
|
||||
telegramChannel: "", socialGithub: "", socialLinkedin: "",
|
||||
socialTwitter: "", socialTelegram: "", socialInstagram: "",
|
||||
};
|
||||
|
||||
export default function AdminSettingsPage() {
|
||||
const [form, setForm] = useState<Settings>(empty);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/settings")
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
if (data && Object.keys(data).length > 0) setForm({ ...empty, ...data });
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
function set(key: keyof Settings, value: string) {
|
||||
setForm((p) => ({ ...p, [key]: value }));
|
||||
}
|
||||
|
||||
async function handleSave(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null); setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/admin/settings", {
|
||||
method: "PUT", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).error ?? "Save failed");
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 2000);
|
||||
} catch (e) { setError(e instanceof Error ? e.message : "خطا"); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 text-2xl font-bold text-text-primary">تنظیمات سایت</h1>
|
||||
<form onSubmit={handleSave} className="space-y-8 max-w-2xl">
|
||||
{error && <div className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error}</div>}
|
||||
{success && <div className="rounded-lg border border-success/30 bg-success/10 px-4 py-3 text-sm text-success">تنظیمات ذخیره شد ✓</div>}
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">اطلاعات سایت</h2>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">نام سایت</label>
|
||||
<input type="text" value={form.siteTitle} onChange={(e) => set("siteTitle", e.target.value)} className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
|
||||
<textarea value={form.description} onChange={(e) => set("description", e.target.value)} rows={3} className="admin-input w-full resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">وضعیت فعلی (در صفحه About نمایش داده میشود)</label>
|
||||
<textarea value={form.currentStatus} onChange={(e) => set("currentStatus", e.target.value)} rows={2} className="admin-input w-full resize-none" placeholder="مثلاً: در حال ساخت HyperAccount..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-border bg-surface p-6 space-y-4">
|
||||
<h2 className="font-semibold text-text-primary border-b border-border pb-3">شبکههای اجتماعی</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{[
|
||||
{ key: "socialGithub", label: "GitHub", placeholder: "https://github.com/..." },
|
||||
{ key: "socialLinkedin", label: "LinkedIn", placeholder: "https://linkedin.com/in/..." },
|
||||
{ key: "socialTwitter", label: "Twitter/X", placeholder: "https://twitter.com/..." },
|
||||
{ key: "socialTelegram", label: "Telegram", placeholder: "https://t.me/..." },
|
||||
{ key: "socialInstagram", label: "Instagram", placeholder: "https://instagram.com/..." },
|
||||
{ key: "telegramChannel", label: "کانال تلگرام", placeholder: "https://t.me/channel..." },
|
||||
].map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">{field.label}</label>
|
||||
<input
|
||||
type="url"
|
||||
value={form[field.key as keyof Settings]}
|
||||
onChange={(e) => set(field.key as keyof Settings, e.target.value)}
|
||||
dir="ltr"
|
||||
className="admin-input w-full"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" disabled={saving} className="flex items-center gap-2 rounded-lg bg-accent px-5 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
ذخیره تنظیمات
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/app/admin/timeline/page.tsx
Normal file
143
src/app/admin/timeline/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Pencil, Trash2, Loader2, Save, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/types";
|
||||
|
||||
interface TimelineEvent {
|
||||
id: string; title: string; date: string;
|
||||
description: string; icon: string; category: string; sortOrder: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "career", label: "شغلی" },
|
||||
{ value: "product", label: "محصول" },
|
||||
{ value: "personal", label: "شخصی" },
|
||||
];
|
||||
|
||||
const emptyForm: Omit<TimelineEvent, "id"> = {
|
||||
title: "", date: new Date().toISOString().split("T")[0],
|
||||
description: "", icon: "🚀", category: "career", sortOrder: 0,
|
||||
};
|
||||
|
||||
export default function AdminTimelinePage() {
|
||||
const [events, setEvents] = useState<TimelineEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [form, setForm] = useState<Omit<TimelineEvent, "id">>(emptyForm);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/admin/timeline");
|
||||
setEvents(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
function startEdit(ev: TimelineEvent) {
|
||||
setForm({ title: ev.title, date: new Date(ev.date).toISOString().split("T")[0], description: ev.description ?? "", icon: ev.icon ?? "🚀", category: ev.category, sortOrder: ev.sortOrder });
|
||||
setEditingId(ev.id);
|
||||
setShowNew(false);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
const body = { ...form, date: new Date(form.date).toISOString() };
|
||||
if (editingId) {
|
||||
await fetch(`/api/admin/timeline/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
} else {
|
||||
await fetch("/api/admin/timeline", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
}
|
||||
setSaving(false);
|
||||
setEditingId(null);
|
||||
setShowNew(false);
|
||||
setForm(emptyForm);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm("حذف شود؟")) return;
|
||||
await fetch(`/api/admin/timeline/${id}`, { method: "DELETE" });
|
||||
await load();
|
||||
}
|
||||
|
||||
const showForm = showNew || editingId;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text-primary">تایملاین</h1>
|
||||
<button onClick={() => { setShowNew(true); setEditingId(null); setForm(emptyForm); }} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
|
||||
<Plus size={16} /> رویداد جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 rounded-xl border border-accent/20 bg-surface p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-text-primary">{editingId ? "ویرایش رویداد" : "رویداد جدید"}</h2>
|
||||
<button onClick={() => { setShowNew(false); setEditingId(null); }} className="text-text-secondary hover:text-text-primary"><X size={16} /></button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">عنوان</label>
|
||||
<input type="text" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">تاریخ</label>
|
||||
<input type="date" value={form.date} onChange={(e) => setForm((p) => ({ ...p, date: e.target.value }))} dir="ltr" className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">آیکون (emoji)</label>
|
||||
<input type="text" value={form.icon} onChange={(e) => setForm((p) => ({ ...p, icon: e.target.value }))} className="admin-input w-full" maxLength={4} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی</label>
|
||||
<select value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} className="admin-input w-full">
|
||||
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
|
||||
<textarea value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} rows={2} className="admin-input w-full resize-none" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving || !form.title} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
|
||||
ذخیره
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<p className="text-text-secondary">هنوز رویدادی ندارید</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{events.map((ev) => (
|
||||
<div key={ev.id} className="flex items-start gap-4 rounded-xl border border-border bg-surface p-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full border border-border bg-surface-hover text-lg shrink-0">{ev.icon ?? "●"}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-semibold text-text-primary">{ev.title}</span>
|
||||
<span className={`rounded-full border px-2 py-0.5 font-mono text-xs ${ev.category === "career" ? "text-accent border-accent/30" : ev.category === "product" ? "text-blue-400 border-blue-400/30" : "text-purple-400 border-purple-400/30"}`}>
|
||||
{formatDate(new Date(ev.date), "fa")}
|
||||
</span>
|
||||
</div>
|
||||
{ev.description && <p className="mt-1 text-sm text-text-secondary">{ev.description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={() => startEdit(ev)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"><Pencil size={14} /></button>
|
||||
<button onClick={() => handleDelete(ev.id)} className="flex h-8 w-8 items-center justify-center rounded-lg border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
143
src/app/admin/uses/page.tsx
Normal file
143
src/app/admin/uses/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Plus, Pencil, Trash2, Loader2, Save, X } from "lucide-react";
|
||||
|
||||
interface UsesItem {
|
||||
id: string; title: string; description: string;
|
||||
category: string; url: string; sortOrder: number;
|
||||
}
|
||||
|
||||
const CATEGORIES = [
|
||||
{ value: "development", label: "توسعه" },
|
||||
{ value: "design", label: "طراحی" },
|
||||
{ value: "marketing", label: "بازاریابی" },
|
||||
{ value: "productivity", label: "بهرهوری" },
|
||||
{ value: "hardware", label: "سختافزار" },
|
||||
];
|
||||
|
||||
const empty: Omit<UsesItem, "id"> = { title: "", description: "", category: "development", url: "", sortOrder: 0 };
|
||||
|
||||
export default function AdminUsesPage() {
|
||||
const [items, setItems] = useState<UsesItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [showNew, setShowNew] = useState(false);
|
||||
const [form, setForm] = useState<Omit<UsesItem, "id">>(empty);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function load() {
|
||||
const res = await fetch("/api/admin/uses");
|
||||
setItems(await res.json());
|
||||
setLoading(false);
|
||||
}
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
if (editingId) {
|
||||
await fetch(`/api/admin/uses/${editingId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
} else {
|
||||
await fetch("/api/admin/uses", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form) });
|
||||
}
|
||||
setSaving(false);
|
||||
setEditingId(null);
|
||||
setShowNew(false);
|
||||
setForm(empty);
|
||||
await load();
|
||||
}
|
||||
|
||||
const grouped = CATEGORIES.reduce<Record<string, UsesItem[]>>((acc, cat) => {
|
||||
const catItems = items.filter((i) => i.category === cat.value);
|
||||
if (catItems.length > 0) acc[cat.value] = catItems;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const showForm = showNew || editingId !== null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-text-primary">ابزارها و Uses</h1>
|
||||
<button onClick={() => { setShowNew(true); setEditingId(null); setForm(empty); }} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover transition-colors">
|
||||
<Plus size={16} /> ابزار جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<div className="mb-6 rounded-xl border border-accent/20 bg-surface p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-text-primary">{editingId ? "ویرایش ابزار" : "ابزار جدید"}</h2>
|
||||
<button onClick={() => { setShowNew(false); setEditingId(null); }}><X size={16} className="text-text-secondary hover:text-text-primary" /></button>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">نام ابزار</label>
|
||||
<input type="text" value={form.title} onChange={(e) => setForm((p) => ({ ...p, title: e.target.value }))} className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">دستهبندی</label>
|
||||
<select value={form.category} onChange={(e) => setForm((p) => ({ ...p, category: e.target.value }))} className="admin-input w-full">
|
||||
{CATEGORIES.map((c) => <option key={c.value} value={c.value}>{c.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="sm:col-span-2">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">توضیحات</label>
|
||||
<input type="text" value={form.description} onChange={(e) => setForm((p) => ({ ...p, description: e.target.value }))} className="admin-input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">لینک (اختیاری)</label>
|
||||
<input type="url" value={form.url} onChange={(e) => setForm((p) => ({ ...p, url: e.target.value }))} dir="ltr" className="admin-input w-full" placeholder="https://..." />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1.5">ترتیب</label>
|
||||
<input type="number" value={form.sortOrder} onChange={(e) => setForm((p) => ({ ...p, sortOrder: parseInt(e.target.value) || 0 }))} className="admin-input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={handleSave} disabled={saving || !form.title} className="flex items-center gap-2 rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-background hover:bg-accent-hover disabled:opacity-60 transition-colors">
|
||||
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />} ذخیره
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-20"><Loader2 className="animate-spin text-text-secondary" /></div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const catItems = grouped[cat.value];
|
||||
if (!catItems) return null;
|
||||
return (
|
||||
<div key={cat.value}>
|
||||
<h2 className="mb-3 flex items-center gap-3">
|
||||
<span className="font-mono text-xs text-accent">// {cat.label}</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{catItems.map((item) => (
|
||||
<div key={item.id} className="flex items-start justify-between gap-3 rounded-lg border border-border bg-surface p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-text-primary">{item.title}</div>
|
||||
{item.description && <p className="mt-0.5 text-sm text-text-secondary">{item.description}</p>}
|
||||
{item.url && <a href={item.url} target="_blank" rel="noopener noreferrer" className="mt-1 block font-mono text-xs text-accent truncate hover:underline">{item.url}</a>}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button onClick={() => { setForm({ title: item.title, description: item.description ?? "", category: item.category, url: item.url ?? "", sortOrder: item.sortOrder }); setEditingId(item.id); setShowNew(false); }} className="flex h-7 w-7 items-center justify-center rounded border border-border text-text-secondary hover:border-accent/40 hover:text-accent transition-colors"><Pencil size={12} /></button>
|
||||
<button onClick={async () => { if (!confirm("حذف؟")) return; await fetch(`/api/admin/uses/${item.id}`, { method: "DELETE" }); load(); }} className="flex h-7 w-7 items-center justify-center rounded border border-border text-text-secondary hover:border-danger/40 hover:text-danger transition-colors"><Trash2 size={12} /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{Object.keys(grouped).length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-20 text-center">
|
||||
<p className="text-text-secondary">هنوز ابزاری ندارید</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
src/app/api/admin/auth/route.ts
Normal file
40
src/app/api/admin/auth/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { username, password } = await req.json();
|
||||
if (!username || !password) {
|
||||
return Response.json({ error: "نام کاربری و رمز عبور الزامی است" }, { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.adminUser.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
return Response.json({ error: "نام کاربری یا رمز عبور اشتباه است" }, { status: 401 });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
return Response.json({ error: "نام کاربری یا رمز عبور اشتباه است" }, { status: 401 });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
session.userId = user.id;
|
||||
session.username = user.username;
|
||||
session.isLoggedIn = true;
|
||||
await session.save();
|
||||
|
||||
return Response.json({ success: true, username: user.username });
|
||||
} catch (err) {
|
||||
console.error("[auth/login]", err);
|
||||
return Response.json({ error: "خطای سرور" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE() {
|
||||
const session = await getSession();
|
||||
session.destroy();
|
||||
return Response.json({ success: true });
|
||||
}
|
||||
33
src/app/api/admin/messages/[id]/route.ts
Normal file
33
src/app/api/admin/messages/[id]/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const { isRead } = await req.json();
|
||||
const msg = await prisma.contactMessage.update({
|
||||
where: { id },
|
||||
data: { isRead },
|
||||
});
|
||||
return Response.json(msg);
|
||||
} catch (err) {
|
||||
console.error("[messages/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.contactMessage.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[messages/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
14
src/app/api/admin/messages/route.ts
Normal file
14
src/app/api/admin/messages/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const messages = await prisma.contactMessage.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
return Response.json(messages);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
57
src/app/api/admin/posts/[id]/route.ts
Normal file
57
src/app/api/admin/posts/[id]/route.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const post = await prisma.post.findUnique({
|
||||
where: { id },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
if (!post) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
return Response.json(post);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const { tagIds, ...data } = body;
|
||||
|
||||
await prisma.postTag.deleteMany({ where: { postId: id } });
|
||||
const post = await prisma.post.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...data,
|
||||
tags: tagIds?.length
|
||||
? { create: tagIds.map((tid: string) => ({ tagId: tid })) }
|
||||
: undefined,
|
||||
},
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
return Response.json(post);
|
||||
} catch (err) {
|
||||
console.error("[posts/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.post.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[posts/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
40
src/app/api/admin/posts/route.ts
Normal file
40
src/app/api/admin/posts/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { locale },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
return Response.json(posts);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const { tagIds, ...data } = body;
|
||||
|
||||
const post = await prisma.post.create({
|
||||
data: {
|
||||
...data,
|
||||
tags: tagIds?.length
|
||||
? { create: tagIds.map((id: string) => ({ tagId: id })) }
|
||||
: undefined,
|
||||
},
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
return Response.json(post, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[posts/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/products/[id]/route.ts
Normal file
42
src/app/api/admin/products/[id]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const product = await prisma.product.findUnique({ where: { id } });
|
||||
if (!product) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
return Response.json(product);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const product = await prisma.product.update({ where: { id }, data: body });
|
||||
return Response.json(product);
|
||||
} catch (err) {
|
||||
console.error("[products/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.product.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[products/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/products/route.ts
Normal file
29
src/app/api/admin/products/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
|
||||
const products = await prisma.product.findMany({
|
||||
where: { locale },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return Response.json(products);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const product = await prisma.product.create({ data: body });
|
||||
return Response.json(product, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[products/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
42
src/app/api/admin/projects/[id]/route.ts
Normal file
42
src/app/api/admin/projects/[id]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({ where: { id } });
|
||||
if (!project) return Response.json({ error: "Not found" }, { status: 404 });
|
||||
return Response.json(project);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const project = await prisma.project.update({ where: { id }, data: body });
|
||||
return Response.json(project);
|
||||
} catch (err) {
|
||||
console.error("[projects/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.project.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[projects/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/projects/route.ts
Normal file
29
src/app/api/admin/projects/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const locale = req.nextUrl.searchParams.get("locale") ?? "fa";
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { locale },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return Response.json(projects);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const project = await prisma.project.create({ data: body });
|
||||
return Response.json(project, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[projects/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/settings/route.ts
Normal file
30
src/app/api/admin/settings/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const settings = await prisma.siteSettings.findUnique({ where: { id: "main" } });
|
||||
return Response.json(settings ?? {});
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const { id, updatedAt, ...data } = body;
|
||||
const settings = await prisma.siteSettings.upsert({
|
||||
where: { id: "main" },
|
||||
create: { id: "main", siteTitle: "Ali Taghavi", ...data },
|
||||
update: data,
|
||||
});
|
||||
return Response.json(settings);
|
||||
} catch (err) {
|
||||
console.error("[settings/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
45
src/app/api/admin/setup/route.ts
Normal file
45
src/app/api/admin/setup/route.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// One-time setup endpoint — creates the admin user and default settings.
|
||||
// Disable or remove this route after first use in production.
|
||||
export async function POST(req: Request) {
|
||||
const setupKey = req.headers.get("x-setup-key");
|
||||
if (setupKey !== process.env.SETUP_KEY) {
|
||||
return Response.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = await prisma.adminUser.findFirst();
|
||||
if (existing) {
|
||||
return Response.json({ error: "Admin already exists" }, { status: 409 });
|
||||
}
|
||||
|
||||
const { username, password } = await req.json();
|
||||
if (!username || !password || password.length < 8) {
|
||||
return Response.json({ error: "Username and password (min 8 chars) required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 12);
|
||||
const user = await prisma.adminUser.create({ data: { username, password: hashed } });
|
||||
|
||||
await prisma.siteSettings.upsert({
|
||||
where: { id: "main" },
|
||||
create: {
|
||||
id: "main",
|
||||
siteTitle: "Ali Taghavi",
|
||||
description: "Founder, builder, and strategist based in Tehran.",
|
||||
currentStatus: "در حال ساخت HyperAccount — زیرساخت هویت برای محصولات ایرانی",
|
||||
socialGithub: "https://github.com/alitaghavi",
|
||||
socialLinkedin: "https://linkedin.com/in/alitaghavi",
|
||||
socialTelegram: "https://t.me/alitaghavi",
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return Response.json({ success: true, userId: user.id });
|
||||
} catch (err) {
|
||||
console.error("[setup]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/admin/tags/route.ts
Normal file
29
src/app/api/admin/tags/route.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const tags = await prisma.tag.findMany({ orderBy: { title: "asc" } });
|
||||
return Response.json(tags);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { title, slug } = await req.json();
|
||||
const tag = await prisma.tag.upsert({
|
||||
where: { slug },
|
||||
create: { title, slug },
|
||||
update: { title },
|
||||
});
|
||||
return Response.json(tag, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[tags/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/timeline/[id]/route.ts
Normal file
30
src/app/api/admin/timeline/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const event = await prisma.timelineEvent.update({ where: { id }, data: body });
|
||||
return Response.json(event);
|
||||
} catch (err) {
|
||||
console.error("[timeline/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.timelineEvent.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[timeline/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/timeline/route.ts
Normal file
27
src/app/api/admin/timeline/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const events = await prisma.timelineEvent.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { date: "desc" }],
|
||||
});
|
||||
return Response.json(events);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const event = await prisma.timelineEvent.create({ data: body });
|
||||
return Response.json(event, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[timeline/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
44
src/app/api/admin/upload/route.ts
Normal file
44
src/app/api/admin/upload/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { writeFile, mkdir } from "fs/promises";
|
||||
import path from "path";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
const ALLOWED_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"];
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
|
||||
const formData = await req.formData();
|
||||
const file = formData.get("file") as File | null;
|
||||
|
||||
if (!file) return Response.json({ error: "No file provided" }, { status: 400 });
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
return Response.json({ error: "File type not allowed" }, { status: 400 });
|
||||
}
|
||||
if (file.size > MAX_SIZE) {
|
||||
return Response.json({ error: "File too large (max 10MB)" }, { status: 400 });
|
||||
}
|
||||
|
||||
const bytes = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(bytes);
|
||||
|
||||
const ext = file.name.split(".").pop() ?? "jpg";
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).slice(2, 8);
|
||||
const filename = `${timestamp}-${random}.${ext}`;
|
||||
|
||||
const uploadDir = path.join(process.cwd(), "public", "uploads");
|
||||
await mkdir(uploadDir, { recursive: true });
|
||||
await writeFile(path.join(uploadDir, filename), buffer);
|
||||
|
||||
return Response.json({ url: `/uploads/${filename}` });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message === "Unauthorized") {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error("[upload]", err);
|
||||
return Response.json({ error: "Upload failed" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
30
src/app/api/admin/uses/[id]/route.ts
Normal file
30
src/app/api/admin/uses/[id]/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
interface Params { params: Promise<{ id: string }> }
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
const body = await req.json();
|
||||
const item = await prisma.usesItem.update({ where: { id }, data: body });
|
||||
return Response.json(item);
|
||||
} catch (err) {
|
||||
console.error("[uses/PUT]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_req: NextRequest, { params }: Params) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const { id } = await params;
|
||||
await prisma.usesItem.delete({ where: { id } });
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[uses/DELETE]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
27
src/app/api/admin/uses/route.ts
Normal file
27
src/app/api/admin/uses/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAuth } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireAuth();
|
||||
const items = await prisma.usesItem.findMany({
|
||||
orderBy: [{ category: "asc" }, { sortOrder: "asc" }],
|
||||
});
|
||||
return Response.json(items);
|
||||
} catch {
|
||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
await requireAuth();
|
||||
const body = await req.json();
|
||||
const item = await prisma.usesItem.create({ data: body });
|
||||
return Response.json(item, { status: 201 });
|
||||
} catch (err) {
|
||||
console.error("[uses/POST]", err);
|
||||
return Response.json({ error: "Server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Resend } from "resend";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
|
||||
@@ -11,23 +12,29 @@ export async function POST(req: NextRequest) {
|
||||
return Response.json({ error: "Missing required fields" }, { status: 400 });
|
||||
}
|
||||
|
||||
await resend.emails.send({
|
||||
from: "biztaghavi.com <noreply@biztaghavi.com>",
|
||||
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
|
||||
replyTo: email,
|
||||
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
|
||||
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<p><strong>نام:</strong> ${name}</p>
|
||||
<p><strong>ایمیل:</strong> ${email}</p>
|
||||
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
|
||||
<hr />
|
||||
<p style="white-space: pre-wrap;">${message}</p>
|
||||
</div>
|
||||
`,
|
||||
await prisma.contactMessage.create({
|
||||
data: { name, email, subject: subject ?? null, message },
|
||||
});
|
||||
|
||||
if (process.env.RESEND_API_KEY) {
|
||||
await resend.emails.send({
|
||||
from: "biztaghavi.com <noreply@biztaghavi.com>",
|
||||
to: [process.env.CONTACT_EMAIL ?? "ali@biztaghavi.com"],
|
||||
replyTo: email,
|
||||
subject: subject ? `[biztaghavi.com] ${subject}` : `[biztaghavi.com] پیام از ${name}`,
|
||||
text: `نام: ${name}\nایمیل: ${email}\n\n${message}`,
|
||||
html: `
|
||||
<div style="font-family: sans-serif; max-width: 600px;">
|
||||
<p><strong>نام:</strong> ${name}</p>
|
||||
<p><strong>ایمیل:</strong> ${email}</p>
|
||||
${subject ? `<p><strong>موضوع:</strong> ${subject}</p>` : ""}
|
||||
<hr />
|
||||
<p style="white-space: pre-wrap;">${message}</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
return Response.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error("[contact]", err);
|
||||
|
||||
@@ -8,6 +8,7 @@ export async function POST(req: NextRequest) {
|
||||
return Response.json({ message: "Invalid secret" }, { status: 401 });
|
||||
}
|
||||
|
||||
revalidateTag("sanity", "default");
|
||||
revalidateTag("posts");
|
||||
revalidateTag("projects");
|
||||
return Response.json({ revalidated: true, now: Date.now() });
|
||||
}
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { sanityFetch } from "@/lib/sanity/client";
|
||||
import { rssPostsQuery } from "@/lib/sanity/queries";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
const SITE_URL = "https://biztaghavi.com";
|
||||
|
||||
export async function GET() {
|
||||
const posts = await sanityFetch<
|
||||
Array<{ _id: string; title: string; slug: { current: string }; excerpt?: string; publishedAt: string; category: string }>
|
||||
>(rssPostsQuery);
|
||||
const posts = await prisma.post.findMany({
|
||||
where: { locale: "fa", publishedAt: { lte: new Date() } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: 20,
|
||||
select: { id: true, title: true, slug: true, excerpt: true, publishedAt: true, category: true },
|
||||
});
|
||||
|
||||
const items = (posts ?? [])
|
||||
const items = posts
|
||||
.map((post) => {
|
||||
const url = `${SITE_URL}/writing/${post.slug.current}`;
|
||||
const url = `${SITE_URL}/writing/${post.slug}`;
|
||||
const date = post.publishedAt ? new Date(post.publishedAt).toUTCString() : "";
|
||||
return `
|
||||
<item>
|
||||
|
||||
@@ -87,3 +87,48 @@ body::before {
|
||||
|
||||
/* Focus ring */
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ── Rich text / Tiptap rendered content ─────────────────────────── */
|
||||
.prose-custom { color: var(--text-primary); line-height: 1.8; }
|
||||
.prose-custom h1 { font-size: 1.875rem; font-weight: 700; margin: 2.5rem 0 1rem; color: var(--text-primary); }
|
||||
.prose-custom h2 { font-size: 1.5rem; font-weight: 700; margin: 2rem 0 0.75rem; color: var(--text-primary); }
|
||||
.prose-custom h3 { font-size: 1.25rem; font-weight: 600; margin: 1.5rem 0 0.5rem; color: var(--text-primary); }
|
||||
.prose-custom h4 { font-size: 1.125rem; font-weight: 600; margin: 1.25rem 0 0.5rem; color: var(--text-primary); }
|
||||
.prose-custom p { margin-bottom: 1.25rem; color: rgba(250,250,250,0.9); }
|
||||
.prose-custom p:last-child { margin-bottom: 0; }
|
||||
.prose-custom ul { list-style: disc; padding-inline-start: 1.5rem; margin-bottom: 1.25rem; }
|
||||
.prose-custom ol { list-style: decimal; padding-inline-start: 1.5rem; margin-bottom: 1.25rem; }
|
||||
.prose-custom li { margin-bottom: 0.375rem; color: rgba(250,250,250,0.9); }
|
||||
.prose-custom blockquote { border-inline-start: 2px solid var(--accent); padding-inline-start: 1.25rem; margin: 1.5rem 0; color: var(--text-secondary); font-style: italic; }
|
||||
.prose-custom a { color: var(--accent); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.prose-custom a:hover { color: var(--accent-hover); }
|
||||
.prose-custom strong { font-weight: 600; color: var(--text-primary); }
|
||||
.prose-custom em { font-style: italic; }
|
||||
.prose-custom code { background: var(--surface); border: 1px solid var(--border-color); border-radius: 4px; padding: 0.125rem 0.375rem; font-family: var(--font-mono, monospace); font-size: 0.875em; color: var(--accent); }
|
||||
.prose-custom pre { background: var(--surface); border: 1px solid var(--border-color); border-radius: 10px; padding: 1rem; margin: 1.5rem 0; overflow-x: auto; }
|
||||
.prose-custom pre code { background: none; border: none; padding: 0; font-size: 0.875rem; line-height: 1.6; color: var(--text-primary); }
|
||||
.prose-custom hr { border: none; border-top: 1px solid var(--border-color); margin: 2rem 0; }
|
||||
.prose-custom img { max-width: 100%; height: auto; border-radius: 10px; border: 1px solid var(--border-color); margin: 1.5rem 0; }
|
||||
|
||||
/* Tiptap editor placeholder */
|
||||
.tiptap p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: inline-start;
|
||||
color: var(--text-secondary);
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
/* ── Admin input utility class ────────────────────────────────────── */
|
||||
.admin-input {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--surface-hover);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.admin-input::placeholder { color: var(--text-secondary); }
|
||||
.admin-input:focus { outline: none; border-color: rgba(204,253,101,0.5); box-shadow: 0 0 0 3px rgba(204,253,101,0.08); }
|
||||
.admin-input option { background: var(--surface); color: var(--text-primary); }
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { NextStudio } from "next-sanity/studio";
|
||||
import config from "../../../../sanity/sanity.config";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export { metadata, viewport } from "next-sanity/studio";
|
||||
|
||||
export default function StudioPage() {
|
||||
return <NextStudio config={config} />;
|
||||
}
|
||||
137
src/components/admin/AdminSidebar.tsx
Normal file
137
src/components/admin/AdminSidebar.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard, FileText, Briefcase, ShoppingBag,
|
||||
Clock, Wrench, Settings, Mail, LogOut, Menu, X, ChevronRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/admin", label: "داشبورد", icon: LayoutDashboard, exact: true },
|
||||
{ href: "/admin/posts", label: "نوشتهها", icon: FileText },
|
||||
{ href: "/admin/projects", label: "پروژهها", icon: Briefcase },
|
||||
{ href: "/admin/products", label: "محصولات", icon: ShoppingBag },
|
||||
{ href: "/admin/timeline", label: "تایملاین", icon: Clock },
|
||||
{ href: "/admin/uses", label: "ابزارها", icon: Wrench },
|
||||
{ href: "/admin/messages", label: "پیامها", icon: Mail },
|
||||
{ href: "/admin/settings", label: "تنظیمات", icon: Settings },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
username?: string;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
export function AdminSidebar({ username, unreadCount = 0 }: Props) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/admin/auth", { method: "DELETE" });
|
||||
router.push("/admin/login");
|
||||
}
|
||||
|
||||
function isActive(href: string, exact?: boolean) {
|
||||
if (exact) return pathname === href;
|
||||
return pathname.startsWith(href);
|
||||
}
|
||||
|
||||
const SidebarContent = () => (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Logo */}
|
||||
<div className="border-b border-white/10 p-6">
|
||||
<div className="font-mono text-xs text-accent mb-1">// admin panel</div>
|
||||
<div className="font-bold text-text-primary">biztaghavi.com</div>
|
||||
{username && (
|
||||
<div className="mt-1 text-xs text-text-secondary">خوش آمدی، {username}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 overflow-y-auto p-4">
|
||||
{navItems.map((item) => {
|
||||
const active = isActive(item.href, item.exact);
|
||||
return (
|
||||
<a
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setOpen(false)}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm transition-all",
|
||||
active
|
||||
? "bg-accent/10 text-accent border border-accent/20"
|
||||
: "text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
<item.icon size={16} className="shrink-0" />
|
||||
<span>{item.label}</span>
|
||||
{item.href === "/admin/messages" && unreadCount > 0 && (
|
||||
<span className="ms-auto flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1 font-mono text-xs text-background">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{active && <ChevronRight size={12} className="ms-auto text-accent" />}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="border-t border-white/10 p-4 space-y-2">
|
||||
<a
|
||||
href="/"
|
||||
target="_blank"
|
||||
className="flex items-center gap-3 rounded-lg px-3 py-2 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-hover transition-all"
|
||||
>
|
||||
<ChevronRight size={14} className="rotate-180" />
|
||||
مشاهده سایت
|
||||
</a>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-danger/80 hover:text-danger hover:bg-danger/10 transition-all"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
خروج
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile toggle */}
|
||||
<button
|
||||
className="fixed top-4 right-4 z-50 flex h-10 w-10 items-center justify-center rounded-lg border border-border bg-surface text-text-secondary lg:hidden"
|
||||
onClick={() => setOpen(!open)}
|
||||
>
|
||||
{open ? <X size={18} /> : <Menu size={18} />}
|
||||
</button>
|
||||
|
||||
{/* Mobile overlay */}
|
||||
{open && (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Mobile sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
"fixed inset-y-0 right-0 z-40 w-72 transform bg-surface border-l border-border transition-transform duration-300 lg:hidden",
|
||||
open ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Desktop sidebar */}
|
||||
<aside className="hidden lg:flex lg:w-64 lg:flex-col lg:fixed lg:inset-y-0 lg:right-0 border-l border-border bg-surface">
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/components/admin/ImageUpload.tsx
Normal file
105
src/components/admin/ImageUpload.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import Image from "next/image";
|
||||
import { Upload, X, Loader2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
onChange: (url: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ImageUpload({ value, onChange, className }: Props) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
const res = await fetch("/api/admin/upload", { method: "POST", body: form });
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "Upload failed");
|
||||
onChange(data.url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDrop(e: React.DragEvent) {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) handleFile(file);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-2", className)}>
|
||||
{value ? (
|
||||
<div className="relative group">
|
||||
<div className="relative h-48 w-full overflow-hidden rounded-lg border border-border">
|
||||
<Image src={value} alt="Cover image" fill className="object-cover" unoptimized />
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange("")}
|
||||
className="absolute top-2 right-2 flex h-8 w-8 items-center justify-center rounded-full bg-danger/90 text-white opacity-0 transition-opacity group-hover:opacity-100"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
<div className="mt-2 font-mono text-xs text-text-secondary truncate">{value}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
className="flex h-40 cursor-pointer flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-border transition-colors hover:border-accent/40 hover:bg-surface-hover"
|
||||
>
|
||||
{uploading ? (
|
||||
<Loader2 size={24} className="animate-spin text-accent" />
|
||||
) : (
|
||||
<>
|
||||
<Upload size={24} className="text-text-secondary" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium text-text-primary">آپلود تصویر</p>
|
||||
<p className="text-xs text-text-secondary">کلیک یا کشیدن فایل — JPG, PNG, WebP تا ۱۰ مگ</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
|
||||
{/* URL input */}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="یا آدرس URL عکس را وارد کنید..."
|
||||
value={value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="flex-1 rounded-lg border border-border bg-surface-hover px-3 py-2 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFile(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
184
src/components/admin/RichTextEditor.tsx
Normal file
184
src/components/admin/RichTextEditor.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import { useEditor, EditorContent } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import TextDirection from "@tiptap/extension-text-direction";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import {
|
||||
Bold, Italic, Strikethrough, Code, Heading1, Heading2, Heading3,
|
||||
List, ListOrdered, Quote, Minus, Link as LinkIcon, Image as ImageIcon,
|
||||
AlignRight, AlignLeft, Undo, Redo,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (html: string) => void;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"flex h-8 w-8 items-center justify-center rounded text-sm transition-colors",
|
||||
active
|
||||
? "bg-accent/20 text-accent"
|
||||
: "text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RichTextEditor({ value, onChange, placeholder, className }: Props) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Placeholder.configure({ placeholder: placeholder ?? "محتوا را بنویسید..." }),
|
||||
Link.configure({ openOnClick: false, HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" } }),
|
||||
Image.configure({ inline: false }),
|
||||
TextDirection.configure({ types: ["heading", "paragraph"] }),
|
||||
],
|
||||
content: value || "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: "min-h-[320px] outline-none prose-custom max-w-none p-4",
|
||||
},
|
||||
},
|
||||
onUpdate({ editor }) {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value || "");
|
||||
}
|
||||
}, [value, editor]);
|
||||
|
||||
const setLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const url = window.prompt("URL:", editor.getAttributes("link").href ?? "");
|
||||
if (url === null) return;
|
||||
if (url === "") {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
} else {
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
const addImage = useCallback(() => {
|
||||
if (!editor) return;
|
||||
const url = window.prompt("URL عکس:");
|
||||
if (url) editor.chain().focus().setImage({ src: url }).run();
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-lg border border-border bg-surface overflow-hidden", className)}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap gap-0.5 border-b border-border p-2 bg-surface-hover">
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBold().run()} active={editor.isActive("bold")} title="Bold">
|
||||
<Bold size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleItalic().run()} active={editor.isActive("italic")} title="Italic">
|
||||
<Italic size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleStrike().run()} active={editor.isActive("strike")} title="Strikethrough">
|
||||
<Strikethrough size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleCode().run()} active={editor.isActive("code")} title="Inline code">
|
||||
<Code size={14} />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} active={editor.isActive("heading", { level: 1 })} title="H1">
|
||||
<Heading1 size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} active={editor.isActive("heading", { level: 2 })} title="H2">
|
||||
<Heading2 size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} active={editor.isActive("heading", { level: 3 })} title="H3">
|
||||
<Heading3 size={14} />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBulletList().run()} active={editor.isActive("bulletList")} title="Bullet list">
|
||||
<List size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleOrderedList().run()} active={editor.isActive("orderedList")} title="Numbered list">
|
||||
<ListOrdered size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleBlockquote().run()} active={editor.isActive("blockquote")} title="Blockquote">
|
||||
<Quote size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().toggleCodeBlock().run()} active={editor.isActive("codeBlock")} title="Code block">
|
||||
<Code size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().setHorizontalRule().run()} active={false} title="Horizontal rule">
|
||||
<Minus size={14} />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
<ToolbarButton onClick={setLink} active={editor.isActive("link")} title="Link">
|
||||
<LinkIcon size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={addImage} active={false} title="Image">
|
||||
<ImageIcon size={14} />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextDirection("rtl").run()}
|
||||
active={false}
|
||||
title="RTL (فارسی)"
|
||||
>
|
||||
<AlignRight size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().setTextDirection("ltr").run()}
|
||||
active={false}
|
||||
title="LTR (English)"
|
||||
>
|
||||
<AlignLeft size={14} />
|
||||
</ToolbarButton>
|
||||
|
||||
<div className="mx-1 h-8 w-px bg-border" />
|
||||
|
||||
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} active={false} title="Undo">
|
||||
<Undo size={14} />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} active={false} title="Redo">
|
||||
<Redo size={14} />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
|
||||
{/* Editor content */}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
src/components/admin/TagsInput.tsx
Normal file
112
src/components/admin/TagsInput.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, Plus, Loader2 } from "lucide-react";
|
||||
import { slugify } from "@/lib/types";
|
||||
|
||||
interface Tag { id: string; title: string; slug: string }
|
||||
interface Props {
|
||||
value: string[];
|
||||
onChange: (tagIds: string[]) => void;
|
||||
}
|
||||
|
||||
export function TagsInput({ value, onChange }: Props) {
|
||||
const [allTags, setAllTags] = useState<Tag[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/admin/tags")
|
||||
.then((r) => r.json())
|
||||
.then(setAllTags)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const selectedTags = allTags.filter((t) => value.includes(t.id));
|
||||
const suggestions = allTags.filter(
|
||||
(t) => !value.includes(t.id) && t.title.toLowerCase().includes(input.toLowerCase())
|
||||
);
|
||||
|
||||
async function addTag(title: string) {
|
||||
const slug = slugify(title);
|
||||
const res = await fetch("/api/admin/tags", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, slug }),
|
||||
});
|
||||
const tag = await res.json();
|
||||
if (!allTags.find((t) => t.id === tag.id)) {
|
||||
setAllTags((prev) => [...prev, tag]);
|
||||
}
|
||||
onChange([...value, tag.id]);
|
||||
setInput("");
|
||||
}
|
||||
|
||||
function removeTag(id: string) {
|
||||
onChange(value.filter((v) => v !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2 min-h-10 rounded-lg border border-border bg-surface p-2">
|
||||
{selectedTags.map((tag) => (
|
||||
<span
|
||||
key={tag.id}
|
||||
className="flex items-center gap-1 rounded-full bg-accent/10 px-2.5 py-1 text-xs font-medium text-accent border border-accent/20"
|
||||
>
|
||||
{tag.title}
|
||||
<button type="button" onClick={() => removeTag(tag.id)}>
|
||||
<X size={10} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && input.trim()) {
|
||||
e.preventDefault();
|
||||
const existing = allTags.find((t) => t.title.toLowerCase() === input.toLowerCase());
|
||||
if (existing && !value.includes(existing.id)) {
|
||||
onChange([...value, existing.id]);
|
||||
setInput("");
|
||||
} else if (!existing) {
|
||||
addTag(input.trim());
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder={loading ? "در حال بارگذاری..." : "تگ جدید + Enter"}
|
||||
className="min-w-32 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
{loading && <Loader2 size={14} className="animate-spin text-text-secondary" />}
|
||||
</div>
|
||||
|
||||
{input && suggestions.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-surface shadow-lg overflow-hidden">
|
||||
{suggestions.slice(0, 5).map((tag) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => { onChange([...value, tag.id]); setInput(""); }}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-text-secondary hover:bg-surface-hover hover:text-text-primary"
|
||||
>
|
||||
<Plus size={12} />
|
||||
{tag.title}
|
||||
</button>
|
||||
))}
|
||||
{!suggestions.find((t) => t.title.toLowerCase() === input.toLowerCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addTag(input.trim())}
|
||||
className="flex w-full items-center gap-2 px-3 py-2 text-sm text-accent hover:bg-accent/10"
|
||||
>
|
||||
<Plus size={12} />
|
||||
ایجاد «{input}»
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,39 +3,28 @@ import { ArrowRight, ArrowLeft } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { SectionHeader } from "@/components/shared/SectionHeader";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { getLatestPosts, estimateReadTime } from "@/lib/db/posts";
|
||||
import { formatDate } from "@/lib/types";
|
||||
|
||||
// Placeholder posts — will come from Sanity in Phase 2
|
||||
const placeholderPosts = [
|
||||
{
|
||||
slug: "building-from-tehran",
|
||||
title: "ساختن از تهران: چالشها و فرصتها",
|
||||
titleEn: "Building from Tehran: Challenges and Opportunities",
|
||||
category: "Founder Notes",
|
||||
date: "1403/02/01",
|
||||
readTime: "5 دقیقه",
|
||||
},
|
||||
{
|
||||
slug: "product-thinking-persian-market",
|
||||
title: "تفکر محصول برای بازار ایرانی",
|
||||
titleEn: "Product Thinking for the Persian Market",
|
||||
category: "Product Thinking",
|
||||
date: "1403/01/25",
|
||||
readTime: "8 دقیقه",
|
||||
},
|
||||
{
|
||||
slug: "node-group-story",
|
||||
title: "داستان NODE-Group: از ایده تا محصول",
|
||||
titleEn: "The NODE-Group Story: From Idea to Product",
|
||||
category: "Business Experiments",
|
||||
date: "1403/01/15",
|
||||
readTime: "12 دقیقه",
|
||||
},
|
||||
];
|
||||
// Server component — fetches fresh posts from DB
|
||||
export async function LatestWriting({ locale }: { locale: string }) {
|
||||
const posts = await getLatestPosts(locale, 5);
|
||||
|
||||
export function LatestWriting() {
|
||||
return <LatestWritingUI posts={posts} locale={locale} />;
|
||||
}
|
||||
|
||||
function LatestWritingUI({
|
||||
posts,
|
||||
locale,
|
||||
}: {
|
||||
posts: Awaited<ReturnType<typeof getLatestPosts>>;
|
||||
locale: string;
|
||||
}) {
|
||||
const t = useTranslations("sections");
|
||||
const tCommon = useTranslations("common");
|
||||
|
||||
if (posts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="relative z-10 px-6 py-16">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
@@ -50,32 +39,39 @@ export function LatestWriting() {
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-border">
|
||||
{placeholderPosts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/writing/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 py-5 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{post.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">{post.date}</span>
|
||||
{posts.map((post) => {
|
||||
const readTime = post.body ? estimateReadTime(post.body) : null;
|
||||
return (
|
||||
<Link
|
||||
key={post.id}
|
||||
href={`/writing/${post.slug}`}
|
||||
className="group flex items-start justify-between gap-4 py-5 hover:text-text-primary transition-colors"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{post.category}
|
||||
</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
{formatDate(post.publishedAt, locale)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-primary group-hover:text-accent transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-base font-medium text-text-primary group-hover:text-accent transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 shrink-0">
|
||||
<span className="font-mono text-xs text-text-secondary">{post.readTime}</span>
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className="text-text-secondary opacity-0 transition-opacity group-hover:opacity-100 rtl:rotate-180"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center gap-2 mt-1 shrink-0">
|
||||
{readTime && (
|
||||
<span className="font-mono text-xs text-text-secondary">{readTime} دقیقه</span>
|
||||
)}
|
||||
<ArrowLeft
|
||||
size={14}
|
||||
className="text-text-secondary opacity-0 transition-opacity group-hover:opacity-100 rtl:rotate-180"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -3,27 +3,22 @@ import { ExternalLink, Github } from "lucide-react";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import type { Project } from "@/lib/sanity/types";
|
||||
import type { FlatProject } from "@/lib/types";
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: Project;
|
||||
project: FlatProject;
|
||||
typeLabel?: string;
|
||||
}
|
||||
|
||||
export function ProjectCard({ project, typeLabel }: ProjectCardProps) {
|
||||
const imageUrl = project.coverImage?.asset
|
||||
? urlFor(project.coverImage).width(800).height(450).url()
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Link href={`/work/${project.slug.current}`} className="group block h-full">
|
||||
<Link href={`/work/${project.slug}`} className="group block h-full">
|
||||
<Card className="h-full overflow-hidden">
|
||||
{imageUrl ? (
|
||||
{project.coverImage ? (
|
||||
<div className="relative h-48 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={project.coverImage?.alt ?? project.title}
|
||||
src={project.coverImage}
|
||||
alt={project.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
@@ -73,7 +68,7 @@ export function ProjectCard({ project, typeLabel }: ProjectCardProps) {
|
||||
<p className="text-sm text-text-secondary line-clamp-3">{project.description}</p>
|
||||
)}
|
||||
|
||||
{project.techStack && project.techStack.length > 0 && (
|
||||
{project.techStack.length > 0 && (
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{project.techStack.slice(0, 4).map((tech) => (
|
||||
<span key={tech} className="rounded-full bg-surface-hover px-2 py-0.5 font-mono text-xs text-text-secondary">
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useState, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ProjectCard } from "./ProjectCard";
|
||||
import type { Project, ProjectType } from "@/lib/sanity/types";
|
||||
import type { FlatProject, ProjectType } from "@/lib/types";
|
||||
|
||||
const types: Array<ProjectType | "all"> = ["all", "product", "brand-system", "open-source", "creative-work"];
|
||||
|
||||
export function WorkListClient({ projects }: { projects: Project[] }) {
|
||||
export function WorkListClient({ projects }: { projects: FlatProject[] }) {
|
||||
const [active, setActive] = useState<ProjectType | "all">("all");
|
||||
const tType = useTranslations("work.types");
|
||||
|
||||
@@ -41,7 +41,7 @@ export function WorkListClient({ projects }: { projects: Project[] }) {
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((project) => (
|
||||
<ProjectCard key={project._id} project={project} typeLabel={tType(project.projectType)} />
|
||||
<ProjectCard key={project.id} project={project} typeLabel={tType(project.projectType)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { PostCategory } from "@/lib/sanity/types";
|
||||
import type { PostCategory } from "@/lib/types";
|
||||
|
||||
const categories: Array<PostCategory | "all"> = [
|
||||
"all",
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import { PortableText, type PortableTextComponents, type PortableTextBlock } from "@portabletext/react";
|
||||
import Image from "next/image";
|
||||
import { highlight } from "sugar-high";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
|
||||
function CodeBlock({ value }: { value: { code: string; language?: string } }) {
|
||||
const html = highlight(value.code ?? "");
|
||||
return (
|
||||
<div className="my-6 overflow-hidden rounded-lg border border-border bg-surface">
|
||||
{value.language && (
|
||||
<div className="flex items-center border-b border-border px-4 py-2">
|
||||
<span className="font-mono text-xs text-accent">{value.language}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre className="overflow-x-auto p-4">
|
||||
<code
|
||||
className="font-mono text-sm leading-relaxed"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PostImage({ value }: { value: { asset: unknown; alt?: string } }) {
|
||||
if (!value?.asset) return null;
|
||||
const url = urlFor(value).width(1200).url();
|
||||
return (
|
||||
<figure className="my-8">
|
||||
<div className="relative aspect-video overflow-hidden rounded-lg border border-border">
|
||||
<Image src={url} alt={value.alt ?? ""} fill className="object-cover" />
|
||||
</div>
|
||||
{value.alt && (
|
||||
<figcaption className="mt-2 text-center text-sm text-text-secondary">{value.alt}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
|
||||
const components = {
|
||||
types: {
|
||||
code: CodeBlock,
|
||||
image: PostImage,
|
||||
},
|
||||
block: {
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="mb-4 mt-10 text-3xl font-bold text-text-primary">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className="mb-3 mt-8 text-2xl font-bold text-text-primary">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className="mb-3 mt-6 text-xl font-semibold text-text-primary">{children}</h3>
|
||||
),
|
||||
h4: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h4 className="mb-2 mt-5 text-lg font-semibold text-text-primary">{children}</h4>
|
||||
),
|
||||
normal: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="mb-5 leading-relaxed text-text-primary/90">{children}</p>
|
||||
),
|
||||
blockquote: ({ children }: { children?: React.ReactNode }) => (
|
||||
<blockquote className="my-6 border-s-2 border-accent ps-5 text-text-secondary italic">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
},
|
||||
list: {
|
||||
bullet: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="mb-5 list-disc ps-6 space-y-2 text-text-primary/90">{children}</ul>
|
||||
),
|
||||
number: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="mb-5 list-decimal ps-6 space-y-2 text-text-primary/90">{children}</ol>
|
||||
),
|
||||
},
|
||||
marks: {
|
||||
strong: ({ children }: { children?: React.ReactNode }) => (
|
||||
<strong className="font-semibold text-text-primary">{children}</strong>
|
||||
),
|
||||
em: ({ children }: { children?: React.ReactNode }) => (
|
||||
<em className="italic">{children}</em>
|
||||
),
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="rounded bg-surface px-1.5 py-0.5 font-mono text-sm text-accent border border-border">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
link: ({ value, children }: { value?: { href: string }; children?: React.ReactNode }) => (
|
||||
<a
|
||||
href={value?.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent underline underline-offset-2 hover:text-accent-hover"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export function PortableTextRenderer({ value }: { value: unknown[] }) {
|
||||
return (
|
||||
<div className="prose-custom max-w-none">
|
||||
<PortableText value={value as PortableTextBlock[]} components={components as PortableTextComponents} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,27 +3,23 @@ import { useLocale } from "next-intl";
|
||||
import { Link } from "@/lib/i18n/navigation";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { urlFor } from "@/lib/sanity/image";
|
||||
import { formatDate, estimateReadTime } from "@/lib/sanity/utils";
|
||||
import type { Post } from "@/lib/sanity/types";
|
||||
import { formatDate, estimateReadTime } from "@/lib/db/posts";
|
||||
import type { FlatPost } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
post: FlatPost;
|
||||
view?: "grid" | "list";
|
||||
categoryLabel?: string;
|
||||
}
|
||||
|
||||
export function PostCard({ post, view = "grid", categoryLabel }: PostCardProps) {
|
||||
const locale = useLocale();
|
||||
const readTime = post.body ? estimateReadTime(post.body as unknown[]) : null;
|
||||
const imageUrl = post.coverImage?.asset
|
||||
? urlFor(post.coverImage).width(800).height(450).url()
|
||||
: null;
|
||||
const readTime = post.body ? estimateReadTime(post.body) : null;
|
||||
|
||||
if (view === "list") {
|
||||
return (
|
||||
<Link href={`/writing/${post.slug.current}`} className="group block">
|
||||
<Link href={`/writing/${post.slug}`} className="group block">
|
||||
<div className="flex items-start justify-between gap-4 border-b border-border py-5 hover:border-accent/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
@@ -52,19 +48,19 @@ export function PostCard({ post, view = "grid", categoryLabel }: PostCardProps)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/writing/${post.slug.current}`} className="group block h-full">
|
||||
<Link href={`/writing/${post.slug}`} className="group block h-full">
|
||||
<Card className="h-full overflow-hidden">
|
||||
{imageUrl && (
|
||||
{post.coverImage && (
|
||||
<div className="relative h-44 overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={post.coverImage?.alt ?? post.title}
|
||||
src={post.coverImage}
|
||||
alt={post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CardContent className={cn("p-5", !imageUrl && "pt-5")}>
|
||||
<CardContent className={cn("p-5", !post.coverImage && "pt-5")}>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Badge variant="default">{categoryLabel ?? post.category}</Badge>
|
||||
<span className="font-mono text-xs text-text-secondary">
|
||||
|
||||
21
src/components/writing/TiptapRenderer.tsx
Normal file
21
src/components/writing/TiptapRenderer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
interface Props {
|
||||
html: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function TiptapRenderer({ html, className }: Props) {
|
||||
if (!html) return null;
|
||||
return (
|
||||
<div
|
||||
className={`prose-custom max-w-none ${className ?? ""}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function estimateReadTimeFromHtml(html: string): number {
|
||||
if (!html) return 1;
|
||||
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const words = text.split(" ").filter(Boolean).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
@@ -6,10 +6,10 @@ import { LayoutGrid, List, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PostCard } from "./PostCard";
|
||||
import { CategoryFilter } from "./CategoryFilter";
|
||||
import type { Post, PostCategory } from "@/lib/sanity/types";
|
||||
import type { FlatPost, PostCategory } from "@/lib/types";
|
||||
|
||||
interface WritingListClientProps {
|
||||
posts: Post[];
|
||||
posts: FlatPost[];
|
||||
}
|
||||
|
||||
export function WritingListClient({ posts }: WritingListClientProps) {
|
||||
@@ -80,23 +80,13 @@ export function WritingListClient({ posts }: WritingListClientProps) {
|
||||
) : view === "grid" ? (
|
||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((post) => (
|
||||
<PostCard
|
||||
key={post._id}
|
||||
post={post}
|
||||
view="grid"
|
||||
categoryLabel={tCat(post.category)}
|
||||
/>
|
||||
<PostCard key={post.id} post={post} view="grid" categoryLabel={tCat(post.category)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{filtered.map((post) => (
|
||||
<PostCard
|
||||
key={post._id}
|
||||
post={post}
|
||||
view="list"
|
||||
categoryLabel={tCat(post.category)}
|
||||
/>
|
||||
<PostCard key={post.id} post={post} view="list" categoryLabel={tCat(post.category)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
32
src/lib/auth.ts
Normal file
32
src/lib/auth.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { getIronSession } from "iron-session";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export interface SessionData {
|
||||
userId?: string;
|
||||
username?: string;
|
||||
isLoggedIn?: boolean;
|
||||
}
|
||||
|
||||
const sessionOptions = {
|
||||
password: process.env.SESSION_SECRET!,
|
||||
cookieName: "biztaghavi_admin_session",
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
},
|
||||
};
|
||||
|
||||
export async function getSession() {
|
||||
const cookieStore = await cookies();
|
||||
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
||||
}
|
||||
|
||||
export async function requireAuth(): Promise<SessionData> {
|
||||
const session = await getSession();
|
||||
if (!session.isLoggedIn) {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
return session;
|
||||
}
|
||||
11
src/lib/db.ts
Normal file
11
src/lib/db.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["error", "warn"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
72
src/lib/db/posts.ts
Normal file
72
src/lib/db/posts.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Post, Tag } from "@prisma/client";
|
||||
export { formatDate } from "@/lib/types";
|
||||
|
||||
export type PostWithTags = Post & {
|
||||
tags: { tag: Tag }[];
|
||||
};
|
||||
|
||||
export async function getAllPosts(locale: string): Promise<PostWithTags[]> {
|
||||
return prisma.post.findMany({
|
||||
where: { locale },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPostsByCategory(locale: string, category: string): Promise<PostWithTags[]> {
|
||||
return prisma.post.findMany({
|
||||
where: { locale, category },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getFeaturedPosts(locale: string, limit = 4): Promise<PostWithTags[]> {
|
||||
return prisma.post.findMany({
|
||||
where: { locale, featured: true },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getPostBySlug(slug: string): Promise<PostWithTags | null> {
|
||||
return prisma.post.findUnique({
|
||||
where: { slug },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getRelatedPosts(
|
||||
locale: string,
|
||||
category: string,
|
||||
excludeSlug: string,
|
||||
limit = 3
|
||||
): Promise<PostWithTags[]> {
|
||||
return prisma.post.findMany({
|
||||
where: { locale, category, NOT: { slug: excludeSlug } },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllPostSlugs(): Promise<{ slug: string; locale: string }[]> {
|
||||
return prisma.post.findMany({ select: { slug: true, locale: true } });
|
||||
}
|
||||
|
||||
export async function getLatestPosts(locale: string, limit = 5): Promise<PostWithTags[]> {
|
||||
return prisma.post.findMany({
|
||||
where: { locale },
|
||||
include: { tags: { include: { tag: true } } },
|
||||
orderBy: { publishedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
}
|
||||
|
||||
export function estimateReadTime(html: string): number {
|
||||
const text = html.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
||||
const words = text.split(" ").filter(Boolean).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
13
src/lib/db/products.ts
Normal file
13
src/lib/db/products.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Product } from "@prisma/client";
|
||||
|
||||
export async function getAllProducts(locale: string): Promise<Product[]> {
|
||||
return prisma.product.findMany({
|
||||
where: { locale },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
}
|
||||
|
||||
export async function getProductBySlug(slug: string): Promise<Product | null> {
|
||||
return prisma.product.findUnique({ where: { slug } });
|
||||
}
|
||||
47
src/lib/db/projects.ts
Normal file
47
src/lib/db/projects.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Project } from "@prisma/client";
|
||||
|
||||
export type ProjectWithArrays = Omit<Project, "gallery" | "techStack" | "toolsUsed"> & {
|
||||
gallery: string[];
|
||||
techStack: string[];
|
||||
toolsUsed: string[];
|
||||
};
|
||||
|
||||
function parseArrayField(val: string | null): string[] {
|
||||
if (!val) return [];
|
||||
try { return JSON.parse(val); } catch { return []; }
|
||||
}
|
||||
|
||||
export function parseProject(p: Project): ProjectWithArrays {
|
||||
return {
|
||||
...p,
|
||||
gallery: parseArrayField(p.gallery),
|
||||
techStack: parseArrayField(p.techStack),
|
||||
toolsUsed: parseArrayField(p.toolsUsed),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllProjects(locale: string): Promise<ProjectWithArrays[]> {
|
||||
const rows = await prisma.project.findMany({
|
||||
where: { locale },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return rows.map(parseProject);
|
||||
}
|
||||
|
||||
export async function getProjectsByType(locale: string, projectType: string): Promise<ProjectWithArrays[]> {
|
||||
const rows = await prisma.project.findMany({
|
||||
where: { locale, projectType },
|
||||
orderBy: [{ sortOrder: "asc" }, { createdAt: "desc" }],
|
||||
});
|
||||
return rows.map(parseProject);
|
||||
}
|
||||
|
||||
export async function getProjectBySlug(slug: string): Promise<ProjectWithArrays | null> {
|
||||
const row = await prisma.project.findUnique({ where: { slug } });
|
||||
return row ? parseProject(row) : null;
|
||||
}
|
||||
|
||||
export async function getAllProjectSlugs(): Promise<{ slug: string; locale: string }[]> {
|
||||
return prisma.project.findMany({ select: { slug: true, locale: true } });
|
||||
}
|
||||
14
src/lib/db/settings.ts
Normal file
14
src/lib/db/settings.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { SiteSettings } from "@prisma/client";
|
||||
|
||||
export async function getSiteSettings(): Promise<SiteSettings | null> {
|
||||
return prisma.siteSettings.findUnique({ where: { id: "main" } });
|
||||
}
|
||||
|
||||
export async function upsertSiteSettings(data: Partial<Omit<SiteSettings, "id" | "updatedAt">>): Promise<SiteSettings> {
|
||||
return prisma.siteSettings.upsert({
|
||||
where: { id: "main" },
|
||||
create: { id: "main", siteTitle: "Ali Taghavi", ...data },
|
||||
update: data,
|
||||
});
|
||||
}
|
||||
8
src/lib/db/timeline.ts
Normal file
8
src/lib/db/timeline.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { TimelineEvent } from "@prisma/client";
|
||||
|
||||
export async function getAllTimelineEvents(): Promise<TimelineEvent[]> {
|
||||
return prisma.timelineEvent.findMany({
|
||||
orderBy: [{ sortOrder: "asc" }, { date: "desc" }],
|
||||
});
|
||||
}
|
||||
8
src/lib/db/uses.ts
Normal file
8
src/lib/db/uses.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { UsesItem } from "@prisma/client";
|
||||
|
||||
export async function getAllUsesItems(): Promise<UsesItem[]> {
|
||||
return prisma.usesItem.findMany({
|
||||
orderBy: [{ category: "asc" }, { sortOrder: "asc" }, { title: "asc" }],
|
||||
});
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { createClient } from "next-sanity";
|
||||
|
||||
export const client = createClient({
|
||||
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
||||
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET ?? "production",
|
||||
apiVersion: "2024-01-01",
|
||||
useCdn: process.env.NODE_ENV === "production",
|
||||
token: process.env.SANITY_API_TOKEN,
|
||||
});
|
||||
|
||||
export async function sanityFetch<T>(
|
||||
query: string,
|
||||
params: Record<string, unknown> = {}
|
||||
): Promise<T> {
|
||||
return client.fetch<T>(query, params, {
|
||||
next: { tags: ["sanity"], revalidate: process.env.NODE_ENV === "development" ? 0 : 3600 },
|
||||
});
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import imageUrlBuilder from "@sanity/image-url";
|
||||
import type { SanityImageSource } from "@sanity/image-url/lib/types/types";
|
||||
import { client } from "./client";
|
||||
|
||||
const builder = imageUrlBuilder(client);
|
||||
|
||||
export function urlFor(source: SanityImageSource) {
|
||||
return builder.image(source);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import { groq } from "next-sanity";
|
||||
|
||||
// ── Fragments ──────────────────────────────────────────────────────────────
|
||||
|
||||
const postFields = groq`
|
||||
_id, title, slug, locale, category, excerpt, publishedAt, featured,
|
||||
"coverImage": coverImage { asset->, alt },
|
||||
"tags": tags[]->{ _id, title, slug },
|
||||
"author": author->{ name, image }
|
||||
`;
|
||||
|
||||
const projectFields = groq`
|
||||
_id, title, slug, locale, projectType, description, featured,
|
||||
techStack, toolsUsed, liveUrl, githubUrl,
|
||||
"coverImage": coverImage { asset->, alt }
|
||||
`;
|
||||
|
||||
// ── Posts ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const allPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale] | order(publishedAt desc) {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const postsByCategoryQuery = groq`
|
||||
*[_type == "post" && locale == $locale && category == $category] | order(publishedAt desc) {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const featuredPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale && featured == true] | order(publishedAt desc)[0...4] {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const postBySlugQuery = groq`
|
||||
*[_type == "post" && slug.current == $slug][0] {
|
||||
${postFields},
|
||||
body,
|
||||
"seo": seo { title, description, "ogImage": ogImage.asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
export const relatedPostsQuery = groq`
|
||||
*[_type == "post" && locale == $locale && category == $category && slug.current != $slug] | order(publishedAt desc)[0...3] {
|
||||
${postFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const allPostSlugsQuery = groq`
|
||||
*[_type == "post"] { "slug": slug.current, locale }
|
||||
`;
|
||||
|
||||
// ── Projects ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const allProjectsQuery = groq`
|
||||
*[_type == "project" && locale == $locale] | order(_createdAt desc) {
|
||||
${projectFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const projectsByTypeQuery = groq`
|
||||
*[_type == "project" && locale == $locale && projectType == $projectType] | order(_createdAt desc) {
|
||||
${projectFields}
|
||||
}
|
||||
`;
|
||||
|
||||
export const projectBySlugQuery = groq`
|
||||
*[_type == "project" && slug.current == $slug][0] {
|
||||
${projectFields},
|
||||
body,
|
||||
"gallery": gallery[] { asset->, alt },
|
||||
"seo": seo { title, description, "ogImage": ogImage.asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
export const allProjectSlugsQuery = groq`
|
||||
*[_type == "project"] { "slug": slug.current, locale }
|
||||
`;
|
||||
|
||||
// ── Site Settings ──────────────────────────────────────────────────────────
|
||||
|
||||
export const siteSettingsQuery = groq`
|
||||
*[_type == "siteSettings"][0] {
|
||||
siteTitle, description, currentStatus, telegramChannel, socialLinks,
|
||||
"defaultOgImage": defaultOgImage.asset->
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Products ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const allProductsQuery = groq`
|
||||
*[_type == "product" && locale == $locale] | order(_createdAt desc) {
|
||||
_id, title, slug, description, price, currency, productType, purchaseUrl, featured,
|
||||
"coverImage": coverImage { asset->, alt }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── Uses items ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const allUsesItemsQuery = groq`
|
||||
*[_type == "usesItem"] | order(category asc, title asc) {
|
||||
_id, title, description, category, url, "image": image { asset-> }
|
||||
}
|
||||
`;
|
||||
|
||||
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
|
||||
|
||||
export const rssPostsQuery = groq`
|
||||
*[_type == "post" && locale == "fa"] | order(publishedAt desc)[0...20] {
|
||||
_id, title, slug, excerpt, publishedAt, category
|
||||
}
|
||||
`;
|
||||
@@ -1,63 +0,0 @@
|
||||
export type PostCategory =
|
||||
| "founder-notes"
|
||||
| "marketing-branding"
|
||||
| "product-thinking"
|
||||
| "tech-builds"
|
||||
| "business-experiments"
|
||||
| "systems-productivity";
|
||||
|
||||
export type ProjectType = "product" | "brand-system" | "open-source" | "creative-work";
|
||||
|
||||
export interface SanityImage {
|
||||
asset: { url: string; metadata: { lqip: string; dimensions: { width: number; height: number } } };
|
||||
alt?: string;
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
locale: "fa" | "en";
|
||||
category: PostCategory;
|
||||
excerpt?: string;
|
||||
publishedAt: string;
|
||||
featured?: boolean;
|
||||
coverImage?: SanityImage;
|
||||
tags?: { _id: string; title: string; slug: { current: string } }[];
|
||||
author?: { name: string; image?: SanityImage };
|
||||
body?: unknown[];
|
||||
seo?: { title?: string; description?: string; ogImage?: { url: string } };
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
_id: string;
|
||||
title: string;
|
||||
slug: { current: string };
|
||||
locale: "fa" | "en";
|
||||
projectType: ProjectType;
|
||||
description?: string;
|
||||
featured?: boolean;
|
||||
coverImage?: SanityImage;
|
||||
gallery?: SanityImage[];
|
||||
techStack?: string[];
|
||||
toolsUsed?: string[];
|
||||
liveUrl?: string;
|
||||
githubUrl?: string;
|
||||
body?: unknown[];
|
||||
seo?: { title?: string; description?: string; ogImage?: { url: string } };
|
||||
}
|
||||
|
||||
export interface SiteSettings {
|
||||
siteTitle?: string;
|
||||
description?: string;
|
||||
currentStatus?: string;
|
||||
telegramChannel?: string;
|
||||
socialLinks?: {
|
||||
github?: string;
|
||||
linkedin?: string;
|
||||
twitter?: string;
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
};
|
||||
defaultOgImage?: { url: string };
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
export function estimateReadTime(body: unknown[]): number {
|
||||
if (!body) return 1;
|
||||
const text = body
|
||||
.filter((b: unknown) => (b as { _type: string })._type === "block")
|
||||
.map((b: unknown) => {
|
||||
const block = b as { children?: { text?: string }[] };
|
||||
return block.children?.map((c) => c.text ?? "").join("") ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
const words = text.trim().split(/\s+/).length;
|
||||
return Math.max(1, Math.ceil(words / 200));
|
||||
}
|
||||
|
||||
export function formatPersianDate(dateStr: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(d);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string, locale: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
year: "numeric", month: "long", day: "numeric",
|
||||
}).format(d);
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
84
src/lib/types.ts
Normal file
84
src/lib/types.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { Post, Project, Product, TimelineEvent, UsesItem, SiteSettings, Tag } from "@prisma/client";
|
||||
import type { PostWithTags } from "@/lib/db/posts";
|
||||
import type { ProjectWithArrays } from "@/lib/db/projects";
|
||||
|
||||
export type PostCategory =
|
||||
| "founder-notes"
|
||||
| "marketing-branding"
|
||||
| "product-thinking"
|
||||
| "tech-builds"
|
||||
| "business-experiments"
|
||||
| "systems-productivity";
|
||||
|
||||
export type ProjectType = "product" | "brand-system" | "open-source" | "creative-work";
|
||||
|
||||
export type { Post, Project, Product, TimelineEvent, UsesItem, SiteSettings, Tag, PostWithTags, ProjectWithArrays };
|
||||
|
||||
export function formatDate(dateStr: string | Date, locale: string): string {
|
||||
if (!dateStr) return "";
|
||||
try {
|
||||
const d = typeof dateStr === "string" ? new Date(dateStr) : dateStr;
|
||||
return new Intl.DateTimeFormat(locale === "fa" ? "fa-IR" : "en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}).format(d);
|
||||
} catch {
|
||||
return String(dateStr);
|
||||
}
|
||||
}
|
||||
|
||||
export function formatPersianDate(date: string | Date): string {
|
||||
if (!date) return "";
|
||||
try {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
return new Intl.DateTimeFormat("fa-IR", { year: "numeric", month: "long", day: "numeric" }).format(d);
|
||||
} catch {
|
||||
return String(date);
|
||||
}
|
||||
}
|
||||
|
||||
// Flat types used by frontend components (Sanity image objects replaced by URL strings)
|
||||
export interface FlatPost {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
locale: string;
|
||||
category: string;
|
||||
excerpt: string | null;
|
||||
publishedAt: Date;
|
||||
featured: boolean;
|
||||
coverImage: string | null;
|
||||
body: string | null;
|
||||
tags: { id: string; title: string; slug: string }[];
|
||||
seoTitle: string | null;
|
||||
seoDesc: string | null;
|
||||
}
|
||||
|
||||
export interface FlatProject {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
locale: string;
|
||||
projectType: string;
|
||||
description: string | null;
|
||||
coverImage: string | null;
|
||||
gallery: string[];
|
||||
techStack: string[];
|
||||
toolsUsed: string[];
|
||||
liveUrl: string | null;
|
||||
githubUrl: string | null;
|
||||
featured: boolean;
|
||||
body: string | null;
|
||||
seoTitle: string | null;
|
||||
seoDesc: string | null;
|
||||
}
|
||||
|
||||
export function slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^\w\u0600-\u06FF-]/g, "")
|
||||
.replace(/-+/g, "-");
|
||||
}
|
||||
Reference in New Issue
Block a user