phase 1-2 done

This commit is contained in:
Ali Taghavi
2026-04-24 03:42:34 +03:30
commit 95a546df11
83 changed files with 17168 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.git
.github
node_modules
.next
.env*.local
.DS_Store
README.md

65
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,65 @@
name: Deploy to VPS
on:
push:
branches: [main]
env:
IMAGE_NAME: biztaghavi
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: latest
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm exec tsc --noEmit
- name: Build Docker image
run: docker build -t $IMAGE_NAME:${{ github.sha }} .
- name: Save Docker image
run: docker save $IMAGE_NAME:${{ github.sha }} | gzip > image.tar.gz
- name: Copy image to VPS
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
source: image.tar.gz
target: /tmp/
- name: Deploy on VPS
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /srv/biztaghavi
docker load < /tmp/image.tar.gz
docker tag biztaghavi:${{ github.sha }} biztaghavi:latest
docker compose up -d --no-build --remove-orphans
docker image prune -f
rm /tmp/image.tar.gz
echo "✅ Deployed ${{ github.sha }}"

42
.gitignore vendored Normal file
View File

@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.claude
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
FROM node:22-alpine AS base
# Install pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
WORKDIR /app
# Dependencies stage
FROM base AS deps
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
RUN pnpm install --frozen-lockfile
# Build stage
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
# Runner stage
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
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
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "zinc",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

42
docker-compose.yml Normal file
View File

@@ -0,0 +1,42 @@
version: "3.9"
services:
next-app:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
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
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
- certbot-webroot:/var/www/certbot
depends_on:
- next-app
networks:
- app-network
volumes:
certbot-webroot:
networks:
app-network:
driver: bridge

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

15
next-sitemap.config.js Normal file
View File

@@ -0,0 +1,15 @@
/** @type {import('next-sitemap').IConfig} */
module.exports = {
siteUrl: "https://biztaghavi.com",
generateRobotsTxt: true,
generateIndexSitemap: false,
changefreq: "weekly",
priority: 0.7,
exclude: ["/studio/*", "/api/*"],
robotsTxtOptions: {
policies: [
{ userAgent: "*", allow: "/" },
{ userAgent: "*", disallow: ["/studio", "/api"] },
],
},
};

15
next.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./src/lib/i18n/request.ts");
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.sanity.io" },
],
},
};
export default withNextIntl(nextConfig);

102
nginx.conf Normal file
View File

@@ -0,0 +1,102 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Redirect HTTP → HTTPS
server {
listen 80;
server_name biztaghavi.com www.biztaghavi.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# Main HTTPS server
server {
listen 443 ssl http2;
server_name biztaghavi.com www.biztaghavi.com;
ssl_certificate /etc/letsencrypt/live/biztaghavi.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/biztaghavi.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
# Redirect www → non-www
if ($host = www.biztaghavi.com) {
return 301 https://biztaghavi.com$request_uri;
}
# Static assets — immutable cache
location /_next/static/ {
proxy_pass http://next-app:3000;
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Next.js image optimization
location /_next/image {
proxy_pass http://next-app:3000;
proxy_cache_valid 200 7d;
add_header Cache-Control "public, max-age=604800";
}
# Main proxy
location / {
proxy_pass http://next-app:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
proxy_read_timeout 60s;
}
}
}

46
package.json Normal file
View File

@@ -0,0 +1,46 @@
{
"name": "biztaghavi",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build && next-sitemap",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"next-intl": "^4.1.0",
"framer-motion": "^12.0.0",
"@fontsource-variable/vazirmatn": "^5.1.0",
"@radix-ui/react-slot": "^1.2.0",
"@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-dialog": "^1.1.0",
"class-variance-authority": "^0.7.1",
"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"
},
"pnpm": {
"onlyBuiltDependencies": ["@parcel/watcher", "@swc/core"]
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

13774
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

3
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,3 @@
ignoredBuiltDependencies:
- sharp
- unrs-resolver

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

36
sanity/sanity.config.ts Normal file
View File

@@ -0,0 +1,36 @@
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) },
});

25
sanity/schemas/author.ts Normal file
View File

@@ -0,0 +1,25 @@
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" } },
});

View File

@@ -0,0 +1,14 @@
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" } },
});

9
sanity/schemas/index.ts Normal file
View File

@@ -0,0 +1,9 @@
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";

56
sanity/schemas/post.ts Normal file
View File

@@ -0,0 +1,56 @@
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" }] }],
});

29
sanity/schemas/product.ts Normal file
View File

@@ -0,0 +1,29 @@
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" } },
});

51
sanity/schemas/project.ts Normal file
View File

@@ -0,0 +1,51 @@
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" } },
});

View File

@@ -0,0 +1,27 @@
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" } },
});

12
sanity/schemas/tag.ts Normal file
View File

@@ -0,0 +1,12 @@
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" } },
});

View File

@@ -0,0 +1,21 @@
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" } },
});

View File

@@ -0,0 +1,29 @@
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" } },
});

View File

@@ -0,0 +1,9 @@
export default function AboutPage() {
return (
<div className="mx-auto max-w-6xl px-6 py-32">
<span className="font-mono text-xs text-accent">// about</span>
<h1 className="mt-4 text-4xl font-bold text-text-primary">درباره علی تقوی</h1>
<p className="mt-4 text-text-secondary">در حال آمادهسازی Phase 3</p>
</div>
);
}

View File

@@ -0,0 +1,9 @@
export default function ContactPage() {
return (
<div className="mx-auto max-w-6xl px-6 py-32">
<span className="font-mono text-xs text-accent">// contact</span>
<h1 className="mt-4 text-4xl font-bold text-text-primary">تماس</h1>
<p className="mt-4 text-text-secondary">در حال آمادهسازی Phase 3</p>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { jetbrainsMono, plusJakartaSans } from "@/lib/fonts";
import { locales, localeDir, type Locale } from "@/lib/i18n/config";
import { Header } from "@/components/layout/Header";
import { Footer } from "@/components/layout/Footer";
import { cn } from "@/lib/utils";
interface LocaleLayoutProps {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}
export function generateStaticParams() {
return locales.map((locale) => ({ locale }));
}
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: string }>;
}): Promise<Metadata> {
const { locale } = await params;
const isRtl = locale === "fa";
return {
alternates: {
canonical: `https://biztaghavi.com/${locale}`,
languages: {
fa: "https://biztaghavi.com/fa",
en: "https://biztaghavi.com/en",
},
},
other: {
"content-language": isRtl ? "fa-IR" : "en-US",
},
};
}
export default async function LocaleLayout({
children,
params,
}: LocaleLayoutProps) {
const { locale } = await params;
if (!locales.includes(locale as Locale)) {
notFound();
}
const messages = await getMessages();
const dir = localeDir[locale as Locale];
const jsonLd = {
"@context": "https://schema.org",
"@type": "Person",
name: "Ali Taghavi",
alternateName: "علی تقوی",
url: "https://biztaghavi.com",
jobTitle: "CEO",
worksFor: { "@type": "Organization", name: "NODE-Group" },
sameAs: [
"https://github.com/alitaghavi",
"https://linkedin.com/in/alitaghavi",
"https://twitter.com/alitaghavi",
],
};
return (
<html
lang={locale}
dir={dir}
className={cn(
jetbrainsMono.variable,
plusJakartaSans.variable,
"h-full antialiased"
)}
>
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<link rel="alternate" type="application/rss+xml" title="علی تقوی — نوشته‌ها" href="/api/rss" />
</head>
<body className="relative flex min-h-full flex-col bg-background text-text-primary">
<NextIntlClientProvider messages={messages}>
<Header />
<main className="flex-1 relative z-10">{children}</main>
<Footer />
</NextIntlClientProvider>
</body>
</html>
);
}

22
src/app/[locale]/page.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { AnimatedSection } from "@/components/shared/AnimatedSection";
import { Hero } from "@/components/home/Hero";
import { BuildingNow } from "@/components/home/BuildingNow";
import { LatestWriting } from "@/components/home/LatestWriting";
import { SignalCTA } from "@/components/home/SignalCTA";
export default function HomePage() {
return (
<>
<Hero />
<AnimatedSection>
<BuildingNow />
</AnimatedSection>
<AnimatedSection delay={0.1}>
<LatestWriting />
</AnimatedSection>
<AnimatedSection delay={0.2}>
<SignalCTA />
</AnimatedSection>
</>
);
}

View File

@@ -0,0 +1,150 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
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 { 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 }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const project = await sanityFetch<Project>(projectBySlugQuery, { slug });
if (!project) return {};
return {
title: project.seo?.title ?? project.title,
description: project.seo?.description ?? project.description,
};
}
export default async function ProjectPage({ params }: Props) {
const { locale, slug } = await params;
const [project, t, tWork] = await Promise.all([
sanityFetch<Project>(projectBySlugQuery, { slug }),
getTranslations({ locale, namespace: "common" }),
getTranslations({ locale, namespace: "work" }),
]);
if (!project) notFound();
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">
<div className="mx-auto max-w-4xl px-6 py-4">
<Link
href="/work"
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-accent transition-colors"
>
<BackArrow size={14} />
{t("back")}
</Link>
</div>
{/* Cover */}
{coverUrl && (
<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" />
</div>
)}
<div className="mx-auto max-w-4xl px-6 pb-16">
{/* Header */}
<header className="mb-10">
<Badge variant="secondary" className="mb-4">{project.projectType}</Badge>
<h1 className="mb-4 text-3xl font-bold text-text-primary md:text-4xl">{project.title}</h1>
{project.description && (
<p className="text-lg text-text-secondary leading-relaxed">{project.description}</p>
)}
{/* CTA buttons */}
<div className="mt-6 flex flex-wrap gap-3">
{project.liveUrl && (
<Button asChild>
<a href={project.liveUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink size={15} /> {t("live_demo")}
</a>
</Button>
)}
{project.githubUrl && (
<Button asChild variant="secondary">
<a href={project.githubUrl} target="_blank" rel="noopener noreferrer">
<Github size={15} /> {t("source_code")}
</a>
</Button>
)}
</div>
</header>
{/* Body */}
{project.body && <PortableTextRenderer value={project.body as unknown[]} />}
{/* 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 && (
<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}
</span>
))}
</div>
</div>
)}
{project.toolsUsed && 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">
{project.toolsUsed.map((tool) => (
<span key={tool} className="rounded-full border border-border px-3 py-1 font-mono text-xs text-text-secondary">
{tool}
</span>
))}
</div>
</div>
)}
</div>
{/* Gallery */}
{project.gallery && 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) => (
<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"
/>
</div>
))}
</div>
</div>
)}
</div>
</article>
);
}

View File

@@ -0,0 +1,35 @@
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";
interface Props {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "work" });
return { title: t("title"), description: t("subtitle") };
}
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 });
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="work" />
<div className="mb-12">
<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 ?? []} />
</div>
);
}

View File

@@ -0,0 +1,169 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
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 { 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";
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 }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await sanityFetch<Post>(postBySlugQuery, { slug });
if (!post) return {};
return {
title: post.seo?.title ?? post.title,
description: post.seo?.description ?? post.excerpt,
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() }]
: [],
},
};
}
export default async function PostPage({ params }: Props) {
const { locale, slug } = await params;
const [post, t, tCommon, tCat] = await Promise.all([
sanityFetch<Post>(postBySlugQuery, { slug }),
getTranslations({ locale, namespace: "writing" }),
getTranslations({ locale, namespace: "common" }),
getTranslations({ locale, namespace: "writing.categories" }),
]);
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 postUrl = `https://biztaghavi.com/${locale}/writing/${slug}`;
const isRtl = locale === "fa";
const BackArrow = isRtl ? ArrowRight : ArrowLeft;
return (
<>
<ReadingProgress />
<article className="relative z-10 pt-24">
{/* Back link */}
<div className="mx-auto max-w-4xl px-6 py-4">
<Link
href="/writing"
className="inline-flex items-center gap-2 text-sm text-text-secondary hover:text-accent transition-colors"
>
<BackArrow size={14} />
{tCommon("back")}
</Link>
</div>
{/* Header */}
<header className="mx-auto max-w-4xl px-6 pb-8">
<div className="mb-4 flex flex-wrap items-center gap-3">
<Badge>{tCat(post.category)}</Badge>
<span className="font-mono text-xs text-text-secondary">
{formatDate(post.publishedAt, locale)}
</span>
{readTime && (
<span className="flex items-center gap-1 font-mono text-xs text-text-secondary">
<Clock size={12} />
{readTime} {tCommon("min_read")}
</span>
)}
</div>
<h1 className="mb-4 text-3xl font-bold leading-tight text-text-primary md:text-4xl lg:text-5xl">
{post.title}
</h1>
{post.excerpt && (
<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>
)}
<ShareButtons title={post.title} url={postUrl} />
</div>
</header>
{/* Cover image */}
{coverUrl && (
<div className="relative mb-12 h-64 w-full overflow-hidden md:h-96">
<Image
src={coverUrl}
alt={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>
)}
{/* Body */}
<div className="mx-auto max-w-3xl px-6 pb-16">
{post.body && <PortableTextRenderer value={post.body as unknown[]} />}
{/* Tags */}
{post.tags && post.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>
))}
</div>
)}
{/* Bottom share */}
<div className="mt-8 flex justify-end">
<ShareButtons title={post.title} url={postUrl} />
</div>
</div>
{/* Related posts */}
{related && related.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)} />
))}
</div>
</div>
</section>
)}
</article>
</>
);
}

View File

@@ -0,0 +1,38 @@
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";
interface Props {
params: Promise<{ locale: string }>;
}
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"),
};
}
export default async function WritingPage({ params }: Props) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "writing" });
const posts = await sanityFetch<Post[]>(allPostsQuery, { locale });
return (
<div className="relative z-10 mx-auto max-w-6xl px-6 py-24 pt-32">
<SectionHeader label="writing" />
<div className="mb-12">
<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 ?? []} />
</div>
);
}

90
src/app/api/og/route.tsx Normal file
View File

@@ -0,0 +1,90 @@
import { ImageResponse } from "next/og";
import type { NextRequest } from "next/server";
export const runtime = "edge";
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const title = searchParams.get("title") ?? "Ali Taghavi";
const category = searchParams.get("category") ?? "";
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
padding: "60px",
background: "#0A0A0B",
fontFamily: "sans-serif",
position: "relative",
}}
>
{/* Dot grid */}
<div
style={{
position: "absolute",
inset: 0,
backgroundImage: "radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px)",
backgroundSize: "28px 28px",
}}
/>
{/* Accent glow */}
<div
style={{
position: "absolute",
top: "-100px",
right: "-100px",
width: "400px",
height: "400px",
borderRadius: "50%",
background: "rgba(204,253,101,0.07)",
filter: "blur(60px)",
}}
/>
{/* Category label */}
{category && (
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "20px",
}}
>
<span style={{ color: "#CCFD65", fontSize: "14px", fontFamily: "monospace" }}>
// {category}
</span>
</div>
)}
{/* Title */}
<div
style={{
fontSize: title.length > 60 ? "36px" : "48px",
fontWeight: "700",
color: "#FAFAFA",
lineHeight: 1.3,
maxWidth: "900px",
marginBottom: "32px",
}}
>
{title}
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ color: "#CCFD65", fontSize: "18px", fontFamily: "monospace", fontWeight: "700" }}>
// ali taghavi
</span>
<span style={{ color: "#A1A1AA", fontSize: "14px" }}>biztaghavi.com</span>
</div>
</div>
),
{ width: 1200, height: 630 }
);
}

View File

@@ -0,0 +1,13 @@
import { revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
export async function POST(req: NextRequest) {
const secret = req.nextUrl.searchParams.get("secret");
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ message: "Invalid secret" }, { status: 401 });
}
revalidateTag("sanity", "default");
return Response.json({ revalidated: true, now: Date.now() });
}

45
src/app/api/rss/route.ts Normal file
View File

@@ -0,0 +1,45 @@
import { sanityFetch } from "@/lib/sanity/client";
import { rssPostsQuery } from "@/lib/sanity/queries";
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 items = (posts ?? [])
.map((post) => {
const url = `${SITE_URL}/writing/${post.slug.current}`;
const date = post.publishedAt ? new Date(post.publishedAt).toUTCString() : "";
return `
<item>
<title><![CDATA[${post.title}]]></title>
<link>${url}</link>
<guid>${url}</guid>
<pubDate>${date}</pubDate>
<category>${post.category}</category>
${post.excerpt ? `<description><![CDATA[${post.excerpt}]]></description>` : ""}
</item>`;
})
.join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>علی تقوی — نوشته‌ها</title>
<link>${SITE_URL}</link>
<description>یادداشت‌ها، تحلیل‌ها و آموخته‌هایم از ساختن کسب‌وکار، محصول و برند</description>
<language>fa</language>
<atom:link href="${SITE_URL}/api/rss" rel="self" type="application/rss+xml" />
${items}
</channel>
</rss>`;
return new Response(xml, {
headers: {
"Content-Type": "application/rss+xml; charset=utf-8",
"Cache-Control": "public, max-age=3600, s-maxage=3600",
},
});
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

89
src/app/globals.css Normal file
View File

@@ -0,0 +1,89 @@
@import "tailwindcss";
@import "@fontsource-variable/vazirmatn";
@theme inline {
/* Brand colors */
--color-background: var(--background);
--color-surface: var(--surface);
--color-surface-hover: var(--surface-hover);
--color-border: var(--border-color);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-muted: var(--accent-muted);
--color-accent-glow: var(--accent-glow);
--color-danger: var(--danger);
--color-success: var(--success);
/* Fonts */
--font-sans: var(--font-jakarta);
--font-mono: var(--font-jetbrains);
--font-persian: "Vazirmatn Variable", "Vazirmatn", system-ui, sans-serif;
/* Radius */
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
}
:root {
--background: #0A0A0B;
--surface: #141416;
--surface-hover: #1C1C1F;
--border-color: #27272A;
--text-primary: #FAFAFA;
--text-secondary: #A1A1AA;
--accent: #CCFD65;
--accent-hover: #B8E85A;
--accent-muted: rgba(204, 253, 101, 0.1);
--accent-glow: rgba(204, 253, 101, 0.25);
--danger: #EF4444;
--success: #22C55E;
}
* {
box-sizing: border-box;
border-color: var(--border-color);
}
html {
color-scheme: dark;
}
body {
background: var(--background);
color: var(--text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
[lang="fa"] body,
[dir="rtl"] {
font-family: "Vazirmatn Variable", "Vazirmatn", system-ui, sans-serif;
line-height: 1.7;
}
/* Dot-grid background texture */
body::before {
content: "";
position: fixed;
inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--background); }
::-webkit-scrollbar-thumb { background: var(--border-color); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); }
/* Selection */
::selection { background: var(--accent-muted); color: var(--accent); }
/* Focus ring */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

19
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: {
default: "Ali Taghavi | علی تقوی",
template: "%s | Ali Taghavi",
},
description: "CEO @ NODE-Group · Builder · Strategist · Tehran, Iran",
metadataBase: new URL("https://biztaghavi.com"),
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return children;
}

View File

@@ -0,0 +1,10 @@
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} />;
}

View File

@@ -0,0 +1,79 @@
import { useTranslations } from "next-intl";
import { ExternalLink } from "lucide-react";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
const products = [
{
name: "HyperAccount",
description: "Identity and account infrastructure for the next web.",
tag: "Product",
href: "https://hyperaccount.ir",
status: "Live",
},
{
name: "Khanehban",
description: "Smart property management for Persian landlords and tenants.",
tag: "Product",
href: "https://khanehban.ir",
status: "Beta",
},
{
name: "NODE-AUTH (Dezhban)",
description: "Authentication & authorization layer built for Persian products.",
tag: "Infrastructure",
href: "https://nodegroup.ir",
status: "Building",
},
];
export function BuildingNow() {
const t = useTranslations("sections");
return (
<section className="relative z-10 px-6 py-16">
<div className="mx-auto max-w-6xl">
<SectionHeader label={t("building_now")} />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{products.map((product) => (
<a
key={product.name}
href={product.href}
target="_blank"
rel="noopener noreferrer"
className="group block"
>
<Card className="h-full p-5">
<CardContent className="p-0">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<Badge variant="secondary">{product.tag}</Badge>
<span className={`font-mono text-xs ${
product.status === "Live" ? "text-success" :
product.status === "Beta" ? "text-accent" : "text-text-secondary"
}`}>
{product.status}
</span>
</div>
<ExternalLink
size={14}
className="text-text-secondary opacity-0 transition-opacity group-hover:opacity-100"
/>
</div>
<h3 className="mb-2 font-mono text-base font-semibold text-text-primary group-hover:text-accent transition-colors">
{product.name}
</h3>
<p className="text-sm text-text-secondary leading-relaxed">
{product.description}
</p>
</CardContent>
</Card>
</a>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useEffect, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations, useLocale } from "next-intl";
import { ArrowRight, ArrowLeft } from "lucide-react";
import { Link } from "@/lib/i18n/navigation";
import { Button } from "@/components/ui/button";
export function Hero() {
const t = useTranslations("hero");
const locale = useLocale();
const isRtl = locale === "fa";
const taglines = t.raw("taglines") as string[];
const [index, setIndex] = useState(0);
useEffect(() => {
const interval = setInterval(() => {
setIndex((i) => (i + 1) % taglines.length);
}, 3500);
return () => clearInterval(interval);
}, [taglines.length]);
const Arrow = isRtl ? ArrowLeft : ArrowRight;
return (
<section className="relative z-10 flex min-h-[90vh] flex-col justify-center px-6 pt-24 pb-16">
<div className="mx-auto w-full max-w-4xl">
{/* Eyebrow */}
<motion.div
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="mb-6 flex items-center gap-2"
>
<span className="font-mono text-xs text-accent">// CEO @ NODE-Group</span>
<span className="h-px flex-1 max-w-16 bg-accent/30" />
</motion.div>
{/* Name */}
<motion.h1
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="mb-6 text-5xl font-bold tracking-tight text-text-primary md:text-7xl"
>
{isRtl ? "علی تقوی" : "Ali Taghavi"}
</motion.h1>
{/* Rotating tagline */}
<div className="mb-8 h-14 overflow-hidden">
<AnimatePresence mode="wait">
<motion.p
key={index}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.4 }}
className="text-xl text-text-secondary md:text-2xl"
>
{taglines[index]}
</motion.p>
</AnimatePresence>
</div>
{/* Bio */}
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="mb-10 max-w-xl text-base text-text-secondary leading-relaxed"
>
{t("bio")}
</motion.p>
{/* CTAs */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.4 }}
className="flex flex-wrap gap-4"
>
<Button asChild size="lg">
<Link href="/work">
{t("cta_work")} <Arrow size={16} />
</Link>
</Button>
<Button asChild variant="secondary" size="lg">
<Link href="/writing">{t("cta_writing")}</Link>
</Button>
</motion.div>
{/* Accent glow */}
<div className="pointer-events-none absolute top-32 start-1/2 -z-10 h-64 w-64 -translate-x-1/2 rounded-full bg-accent/5 blur-3xl" />
</div>
</section>
);
}

View File

@@ -0,0 +1,83 @@
import { useTranslations } from "next-intl";
import { ArrowRight, ArrowLeft } from "lucide-react";
import { Link } from "@/lib/i18n/navigation";
import { SectionHeader } from "@/components/shared/SectionHeader";
import { Badge } from "@/components/ui/badge";
// 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 دقیقه",
},
];
export function LatestWriting() {
const t = useTranslations("sections");
const tCommon = useTranslations("common");
return (
<section className="relative z-10 px-6 py-16">
<div className="mx-auto max-w-6xl">
<div className="flex items-end justify-between mb-8">
<SectionHeader label={t("latest_writing")} className="mb-0" />
<Link
href="/writing"
className="hidden items-center gap-1 text-sm text-text-secondary hover:text-accent transition-colors md:flex"
>
{tCommon("read_more")} <ArrowRight size={14} />
</Link>
</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>
</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>
</div>
</section>
);
}

View File

@@ -0,0 +1,43 @@
import { useTranslations } from "next-intl";
import { Send } from "lucide-react";
import { Button } from "@/components/ui/button";
export function SignalCTA() {
const t = useTranslations("sections");
return (
<section className="relative z-10 px-6 py-16">
<div className="mx-auto max-w-6xl">
<div className="relative overflow-hidden rounded-xl border border-accent/20 bg-surface p-8 md:p-12">
{/* Background glow */}
<div className="pointer-events-none absolute -right-20 -top-20 h-64 w-64 rounded-full bg-accent/5 blur-3xl" />
<div className="relative flex flex-col items-start justify-between gap-6 md:flex-row md:items-center">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-accent-muted">
<Send size={20} className="text-accent" />
</div>
<div>
<h2 className="mb-1 text-xl font-bold text-text-primary">
{t("signal")}
</h2>
<p className="text-sm text-text-secondary">{t("signal_desc")}</p>
</div>
</div>
<Button asChild size="lg" className="shrink-0">
<a
href="https://t.me/alitaghavi_channel"
target="_blank"
rel="noopener noreferrer"
>
<Send size={16} />
{t("signal_cta")}
</a>
</Button>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,74 @@
import { useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import { Github, Linkedin, Send } from "lucide-react";
const socialLinks = [
{ icon: Github, href: "https://github.com/alitaghavi", label: "GitHub" },
{ icon: Linkedin, href: "https://linkedin.com/in/alitaghavi", label: "LinkedIn" },
{ icon: Send, href: "https://t.me/alitaghavi", label: "Telegram" },
];
const navLinks = [
{ href: "/writing", labelKey: "writing" },
{ href: "/work", labelKey: "work" },
{ href: "/about", labelKey: "about" },
{ href: "/contact", labelKey: "contact" },
] as const;
export function Footer() {
const t = useTranslations("nav");
const tf = useTranslations("footer");
return (
<footer className="relative z-10 border-t border-border bg-surface/50">
<div className="mx-auto max-w-6xl px-6 py-12">
<div className="flex flex-col gap-8 md:flex-row md:items-start md:justify-between">
{/* Brand */}
<div className="flex flex-col gap-3">
<Link href="/" className="font-mono text-sm font-bold text-text-primary hover:text-accent transition-colors">
<span className="text-accent">//</span> ali taghavi
</Link>
<p className="max-w-xs text-sm text-text-secondary leading-relaxed">
CEO @ NODE-Group · Builder · Strategist
</p>
</div>
{/* Nav links */}
<nav className="flex flex-wrap gap-x-6 gap-y-2">
{navLinks.map(({ href, labelKey }) => (
<Link
key={href}
href={href}
className="text-sm text-text-secondary hover:text-text-primary transition-colors"
>
{t(labelKey)}
</Link>
))}
</nav>
{/* Social */}
<div className="flex items-center gap-3">
{socialLinks.map(({ icon: Icon, href, label }) => (
<a
key={href}
href={href}
target="_blank"
rel="noopener noreferrer"
aria-label={label}
className="flex h-9 w-9 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent hover:bg-accent-muted"
>
<Icon size={16} />
</a>
))}
</div>
</div>
{/* Bottom bar */}
<div className="mt-10 flex flex-col items-center justify-between gap-3 border-t border-border pt-6 text-xs text-text-secondary md:flex-row">
<span>© {new Date().getFullYear()} Ali Taghavi {tf("rights")}</span>
<span className="font-mono">NODE-Group · Tehran, Iran</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { MobileMenu } from "./MobileMenu";
const navKeys = ["writing", "work", "about", "contact"] as const;
const navHrefs: Record<string, string> = {
writing: "/writing",
work: "/work",
about: "/about",
contact: "/contact",
};
export function Header() {
const [scrolled, setScrolled] = useState(false);
const t = useTranslations("nav");
useEffect(() => {
const handler = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", handler, { passive: true });
return () => window.removeEventListener("scroll", handler);
}, []);
return (
<header
className={cn(
"fixed top-0 z-40 w-full transition-all duration-300",
scrolled
? "border-b border-border bg-background/80 backdrop-blur-md"
: "bg-transparent"
)}
>
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
{/* Logo */}
<Link
href="/"
className="font-mono text-sm font-bold tracking-tight text-text-primary hover:text-accent transition-colors"
>
<span className="text-accent">//</span> ali taghavi
</Link>
{/* Desktop nav */}
<nav className="hidden items-center gap-1 md:flex">
{navKeys.map((key) => (
<Link
key={key}
href={navHrefs[key]}
className="rounded-md px-3 py-2 text-sm text-text-secondary transition-colors hover:text-text-primary hover:bg-surface-hover"
>
{t(key)}
</Link>
))}
</nav>
{/* Right side */}
<div className="flex items-center gap-3">
<div className="hidden md:block">
<LanguageSwitcher />
</div>
<MobileMenu />
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,35 @@
"use client";
import { useLocale } from "next-intl";
import { usePathname, useRouter } from "@/lib/i18n/navigation";
import { locales, localeNames, type Locale } from "@/lib/i18n/config";
import { cn } from "@/lib/utils";
export function LanguageSwitcher() {
const locale = useLocale() as Locale;
const pathname = usePathname();
const router = useRouter();
function switchLocale(next: Locale) {
router.replace(pathname, { locale: next });
}
return (
<div className="flex items-center gap-1 rounded-full border border-border bg-surface px-1 py-1">
{locales.map((l) => (
<button
key={l}
onClick={() => switchLocale(l)}
className={cn(
"rounded-full px-3 py-1 text-xs font-medium transition-all duration-200",
locale === l
? "bg-accent text-background"
: "text-text-secondary hover:text-text-primary"
)}
>
{l === "fa" ? "فا" : "EN"}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { Menu, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { Link } from "@/lib/i18n/navigation";
import { cn } from "@/lib/utils";
import { LanguageSwitcher } from "./LanguageSwitcher";
const navKeys = ["home", "writing", "work", "about", "contact"] as const;
const navHrefs: Record<string, string> = {
home: "/",
writing: "/writing",
work: "/work",
about: "/about",
contact: "/contact",
};
export function MobileMenu() {
const [open, setOpen] = useState(false);
const t = useTranslations("nav");
return (
<>
<button
onClick={() => setOpen(true)}
className="p-2 text-text-secondary hover:text-text-primary md:hidden"
aria-label="Open menu"
>
<Menu size={20} />
</button>
{open && (
<div className="fixed inset-0 z-50 flex flex-col bg-background/95 backdrop-blur-md md:hidden">
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<span className="font-mono text-sm text-accent">// menu</span>
<button
onClick={() => setOpen(false)}
className="p-2 text-text-secondary hover:text-text-primary"
aria-label="Close menu"
>
<X size={20} />
</button>
</div>
<nav className="flex flex-1 flex-col gap-2 px-6 pt-8">
{navKeys.map((key) => (
<Link
key={key}
href={navHrefs[key]}
onClick={() => setOpen(false)}
className="rounded-md px-4 py-3 text-lg font-medium text-text-secondary transition-colors hover:bg-surface hover:text-text-primary"
>
{t(key)}
</Link>
))}
</nav>
<div className="border-t border-border px-6 py-6">
<LanguageSwitcher />
</div>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,28 @@
"use client";
import { useRef } from "react";
import { motion, useInView } from "framer-motion";
import { cn } from "@/lib/utils";
interface AnimatedSectionProps {
children: React.ReactNode;
className?: string;
delay?: number;
}
export function AnimatedSection({ children, className, delay = 0 }: AnimatedSectionProps) {
const ref = useRef(null);
const inView = useInView(ref, { once: true, margin: "-80px" });
return (
<motion.div
ref={ref}
initial={{ opacity: 0, y: 24 }}
animate={inView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay, ease: "easeOut" }}
className={cn(className)}
>
{children}
</motion.div>
);
}

View File

@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
interface SectionHeaderProps {
label: string;
title?: string;
className?: string;
}
export function SectionHeader({ label, title, className }: SectionHeaderProps) {
return (
<div className={cn("mb-8", className)}>
<div className="mb-3 flex items-center gap-3">
<span className="font-mono text-xs text-accent">// {label}</span>
<span className="h-px flex-1 bg-border" />
</div>
{title && (
<h2 className="text-2xl font-bold text-text-primary md:text-3xl">{title}</h2>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
{
variants: {
variant: {
default: "bg-accent-muted text-accent border border-accent/20",
secondary: "bg-surface text-text-secondary border border-border",
outline: "border border-border text-text-secondary",
},
},
defaultVariants: {
variant: "default",
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {
children?: React.ReactNode;
}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,56 @@
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-accent text-background hover:bg-accent-hover font-semibold shadow-[0_0_20px_var(--accent-glow)]",
secondary:
"border border-border bg-surface text-text-primary hover:bg-surface-hover hover:border-accent/40",
ghost:
"text-text-secondary hover:text-text-primary hover:bg-surface-hover",
link: "text-accent underline-offset-4 hover:underline p-0 h-auto",
outline:
"border border-accent/40 text-accent hover:bg-accent-muted",
},
size: {
default: "h-10 px-5 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-12 px-8 text-base",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
children?: React.ReactNode;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button";
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = "Button";
export { Button, buttonVariants };

View File

@@ -0,0 +1,63 @@
import * as React from "react";
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-surface backdrop-blur-sm transition-all duration-200 hover:border-accent/20 hover:bg-surface-hover",
className
)}
{...props}
/>
));
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col gap-1.5 p-6", className)} {...props} />
));
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLHeadingElement,
React.HTMLAttributes<HTMLHeadingElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("text-lg font-semibold leading-tight text-text-primary", className)}
{...props}
/>
));
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<p ref={ref} className={cn("text-sm text-text-secondary", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
));
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & { children?: React.ReactNode }
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
));
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter };

View File

@@ -0,0 +1,25 @@
"use client";
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-px w-full" : "h-full w-px",
className
)}
{...props}
/>
));
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };

View File

@@ -0,0 +1,89 @@
import Image from "next/image";
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";
interface ProjectCardProps {
project: Project;
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">
<Card className="h-full overflow-hidden">
{imageUrl ? (
<div className="relative h-48 overflow-hidden">
<Image
src={imageUrl}
alt={project.coverImage?.alt ?? project.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
/>
</div>
) : (
<div className="h-48 bg-surface-hover flex items-center justify-center">
<span className="font-mono text-2xl text-accent opacity-30">//</span>
</div>
)}
<CardContent className="p-5">
<div className="mb-3 flex items-center justify-between gap-2">
<Badge variant="secondary">{typeLabel ?? project.projectType}</Badge>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{project.liveUrl && (
<a
href={project.liveUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-text-secondary hover:text-accent transition-colors"
aria-label="Live demo"
>
<ExternalLink size={14} />
</a>
)}
{project.githubUrl && (
<a
href={project.githubUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-text-secondary hover:text-accent transition-colors"
aria-label="GitHub"
>
<Github size={14} />
</a>
)}
</div>
</div>
<h3 className="mb-2 font-semibold text-text-primary group-hover:text-accent transition-colors">
{project.title}
</h3>
{project.description && (
<p className="text-sm text-text-secondary line-clamp-3">{project.description}</p>
)}
{project.techStack && 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">
{tech}
</span>
))}
</div>
)}
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,49 @@
"use client";
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";
const types: Array<ProjectType | "all"> = ["all", "product", "brand-system", "open-source", "creative-work"];
export function WorkListClient({ projects }: { projects: Project[] }) {
const [active, setActive] = useState<ProjectType | "all">("all");
const tType = useTranslations("work.types");
const filtered = useMemo(
() => (active === "all" ? projects : projects.filter((p) => p.projectType === active)),
[projects, active]
);
return (
<div>
{/* Type filter */}
<div className="mb-8 flex flex-wrap gap-2">
{types.map((type) => (
<button
key={type}
onClick={() => setActive(type)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200",
active === type
? "bg-accent text-background"
: "border border-border text-text-secondary hover:border-accent/40 hover:text-text-primary"
)}
>
{tType(type)}
</button>
))}
</div>
<p className="mb-6 font-mono text-xs text-text-secondary">{filtered.length} پروژه</p>
<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)} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,43 @@
"use client";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { PostCategory } from "@/lib/sanity/types";
const categories: Array<PostCategory | "all"> = [
"all",
"founder-notes",
"marketing-branding",
"product-thinking",
"tech-builds",
"business-experiments",
"systems-productivity",
];
interface CategoryFilterProps {
active: PostCategory | "all";
onChange: (cat: PostCategory | "all") => void;
}
export function CategoryFilter({ active, onChange }: CategoryFilterProps) {
const t = useTranslations("writing.categories");
return (
<div className="flex flex-wrap gap-2">
{categories.map((cat) => (
<button
key={cat}
onClick={() => onChange(cat)}
className={cn(
"rounded-full px-4 py-1.5 text-sm font-medium transition-all duration-200",
active === cat
? "bg-accent text-background"
: "border border-border text-text-secondary hover:border-accent/40 hover:text-text-primary"
)}
>
{t(cat)}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,106 @@
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>
);
}

View File

@@ -0,0 +1,89 @@
import Image from "next/image";
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 { cn } from "@/lib/utils";
interface PostCardProps {
post: Post;
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;
if (view === "list") {
return (
<Link href={`/writing/${post.slug.current}`} 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">
<Badge variant="default" className="shrink-0">
{categoryLabel ?? post.category}
</Badge>
<span className="font-mono text-xs text-text-secondary">
{formatDate(post.publishedAt, locale)}
</span>
</div>
<h3 className="text-base font-semibold text-text-primary group-hover:text-accent transition-colors line-clamp-2">
{post.title}
</h3>
{post.excerpt && (
<p className="mt-1 text-sm text-text-secondary line-clamp-2">{post.excerpt}</p>
)}
</div>
{readTime && (
<span className="shrink-0 font-mono text-xs text-text-secondary mt-1">
{readTime} دقیقه
</span>
)}
</div>
</Link>
);
}
return (
<Link href={`/writing/${post.slug.current}`} className="group block h-full">
<Card className="h-full overflow-hidden">
{imageUrl && (
<div className="relative h-44 overflow-hidden">
<Image
src={imageUrl}
alt={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")}>
<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">
{formatDate(post.publishedAt, locale)}
</span>
</div>
<h3 className="mb-2 text-base font-semibold text-text-primary group-hover:text-accent transition-colors line-clamp-2">
{post.title}
</h3>
{post.excerpt && (
<p className="text-sm text-text-secondary line-clamp-3">{post.excerpt}</p>
)}
{readTime && (
<div className="mt-4 font-mono text-xs text-text-secondary">
{readTime} دقیقه مطالعه
</div>
)}
</CardContent>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,27 @@
"use client";
import { useEffect, useState } from "react";
export function ReadingProgress() {
const [progress, setProgress] = useState(0);
useEffect(() => {
function update() {
const doc = document.documentElement;
const scrolled = doc.scrollTop;
const total = doc.scrollHeight - doc.clientHeight;
setProgress(total > 0 ? (scrolled / total) * 100 : 0);
}
window.addEventListener("scroll", update, { passive: true });
return () => window.removeEventListener("scroll", update);
}, []);
return (
<div className="fixed top-0 start-0 z-50 h-0.5 w-full bg-transparent">
<div
className="h-full bg-accent transition-all duration-100"
style={{ width: `${progress}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Link2, Twitter, Send, Check } from "lucide-react";
interface ShareButtonsProps {
title: string;
url: string;
}
export function ShareButtons({ title, url }: ShareButtonsProps) {
const [copied, setCopied] = useState(false);
const t = useTranslations("common");
async function copyLink() {
await navigator.clipboard.writeText(url);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
const encodedUrl = encodeURIComponent(url);
const encodedTitle = encodeURIComponent(title);
return (
<div className="flex items-center gap-2">
<span className="text-sm text-text-secondary">{t("share")}:</span>
<a
href={`https://twitter.com/intent/tweet?text=${encodedTitle}&url=${encodedUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
aria-label="Share on Twitter"
>
<Twitter size={14} />
</a>
<a
href={`https://t.me/share/url?url=${encodedUrl}&text=${encodedTitle}`}
target="_blank"
rel="noopener noreferrer"
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
aria-label="Share on Telegram"
>
<Send size={14} />
</a>
<button
onClick={copyLink}
className="flex h-8 w-8 items-center justify-center rounded-full border border-border text-text-secondary transition-all hover:border-accent/40 hover:text-accent"
aria-label={t("copy_link")}
>
{copied ? <Check size={14} className="text-success" /> : <Link2 size={14} />}
</button>
</div>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslations } from "next-intl";
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";
interface WritingListClientProps {
posts: Post[];
}
export function WritingListClient({ posts }: WritingListClientProps) {
const [view, setView] = useState<"grid" | "list">("grid");
const [category, setCategory] = useState<PostCategory | "all">("all");
const [search, setSearch] = useState("");
const t = useTranslations("writing");
const tCommon = useTranslations("common");
const tCat = useTranslations("writing.categories");
const filtered = useMemo(() => {
return posts.filter((p) => {
const matchesCat = category === "all" || p.category === category;
const matchesSearch =
!search ||
p.title.toLowerCase().includes(search.toLowerCase()) ||
(p.excerpt ?? "").toLowerCase().includes(search.toLowerCase());
return matchesCat && matchesSearch;
});
}, [posts, category, search]);
return (
<div>
{/* Controls */}
<div className="mb-8 flex flex-col gap-4">
{/* Search */}
<div className="relative">
<Search size={16} className="absolute start-3 top-1/2 -translate-y-1/2 text-text-secondary" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={tCommon("search_placeholder")}
className="w-full rounded-lg border border-border bg-surface ps-10 pe-4 py-2.5 text-sm text-text-primary placeholder:text-text-secondary focus:border-accent/50 focus:outline-none transition-colors"
/>
</div>
{/* Category filter + view toggle */}
<div className="flex items-start justify-between gap-4">
<CategoryFilter active={category} onChange={setCategory} />
<div className="flex shrink-0 items-center gap-1 rounded-lg border border-border bg-surface p-1">
<button
onClick={() => setView("grid")}
className={cn("rounded p-1.5 transition-colors", view === "grid" ? "bg-accent text-background" : "text-text-secondary hover:text-text-primary")}
aria-label={t("grid_view")}
>
<LayoutGrid size={15} />
</button>
<button
onClick={() => setView("list")}
className={cn("rounded p-1.5 transition-colors", view === "list" ? "bg-accent text-background" : "text-text-secondary hover:text-text-primary")}
aria-label={t("list_view")}
>
<List size={15} />
</button>
</div>
</div>
</div>
{/* Results count */}
<p className="mb-6 font-mono text-xs text-text-secondary">
{filtered.length} نوشته
</p>
{/* Posts */}
{filtered.length === 0 ? (
<div className="py-16 text-center text-text-secondary">{tCommon("no_results")}</div>
) : 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)}
/>
))}
</div>
) : (
<div>
{filtered.map((post) => (
<PostCard
key={post._id}
post={post}
view="list"
categoryLabel={tCat(post.category)}
/>
))}
</div>
)}
</div>
);
}

13
src/lib/fonts.ts Normal file
View File

@@ -0,0 +1,13 @@
import { JetBrains_Mono, Plus_Jakarta_Sans } from "next/font/google";
export const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains",
subsets: ["latin"],
display: "swap",
});
export const plusJakartaSans = Plus_Jakarta_Sans({
variable: "--font-jakarta",
subsets: ["latin"],
display: "swap",
});

13
src/lib/i18n/config.ts Normal file
View File

@@ -0,0 +1,13 @@
export const locales = ["fa", "en"] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = "fa";
export const localeNames: Record<Locale, string> = {
fa: "فارسی",
en: "English",
};
export const localeDir: Record<Locale, "rtl" | "ltr"> = {
fa: "rtl",
en: "ltr",
};

View File

@@ -0,0 +1,5 @@
import { createNavigation } from "next-intl/navigation";
import { routing } from "./routing";
export const { Link, redirect, usePathname, useRouter, getPathname } =
createNavigation(routing);

15
src/lib/i18n/request.ts Normal file
View File

@@ -0,0 +1,15 @@
import { getRequestConfig } from "next-intl/server";
import { routing } from "./routing";
export default getRequestConfig(async ({ requestLocale }) => {
let locale = await requestLocale;
if (!locale || !routing.locales.includes(locale as "fa" | "en")) {
locale = routing.defaultLocale;
}
return {
locale,
messages: (await import(`../../messages/${locale}.json`)).default,
};
});

8
src/lib/i18n/routing.ts Normal file
View File

@@ -0,0 +1,8 @@
import { defineRouting } from "next-intl/routing";
import { locales, defaultLocale } from "./config";
export const routing = defineRouting({
locales,
defaultLocale,
localePrefix: "as-needed",
});

18
src/lib/sanity/client.ts Normal file
View File

@@ -0,0 +1,18 @@
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 },
});
}

9
src/lib/sanity/image.ts Normal file
View File

@@ -0,0 +1,9 @@
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);
}

98
src/lib/sanity/queries.ts Normal file
View File

@@ -0,0 +1,98 @@
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->
}
`;
// ── RSS (all fa posts) ─────────────────────────────────────────────────────
export const rssPostsQuery = groq`
*[_type == "post" && locale == "fa"] | order(publishedAt desc)[0...20] {
_id, title, slug, excerpt, publishedAt, category
}
`;

63
src/lib/sanity/types.ts Normal file
View File

@@ -0,0 +1,63 @@
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 };
}

34
src/lib/sanity/utils.ts Normal file
View File

@@ -0,0 +1,34 @@
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;
}
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

83
src/messages/en.json Normal file
View File

@@ -0,0 +1,83 @@
{
"nav": {
"home": "Home",
"writing": "Writing",
"work": "Work",
"about": "About",
"resume": "Resume",
"shop": "Shop",
"contact": "Contact",
"uses": "Uses"
},
"hero": {
"taglines": [
"Building products, brands, and systems from Tehran",
"CEO @ NODE-Group · Creator · Strategist",
"I build things that work — in code, in markets, in culture"
],
"bio": "I'm Ali Taghavi. A builder from Tehran operating at the intersection of technology, branding, marketing, and product strategy.",
"cta_work": "See my work",
"cta_writing": "Read my writing"
},
"sections": {
"selected_work": "Selected Work",
"building_now": "What I'm Building",
"latest_writing": "Latest Writing",
"signal": "Telegram Channel",
"signal_desc": "Raw notes, ideas, and daily updates",
"signal_cta": "Join the channel"
},
"footer": {
"rights": "All rights reserved",
"built_with": "Built with"
},
"common": {
"read_more": "Read more",
"view_project": "View project",
"back": "Back",
"loading": "Loading...",
"error": "Something went wrong",
"all": "All",
"search_placeholder": "Search...",
"no_results": "No results found",
"min_read": "min read",
"share": "Share",
"copy_link": "Copy link",
"copied": "Copied!",
"table_of_contents": "Table of Contents",
"related_posts": "Related Posts",
"live_demo": "Live Demo",
"source_code": "Source Code"
},
"writing": {
"title": "Writing",
"subtitle": "Notes, analysis, and lessons from building businesses, products, and brands",
"grid_view": "Grid",
"list_view": "List",
"categories": {
"all": "All",
"founder-notes": "Founder Notes",
"marketing-branding": "Marketing & Branding",
"product-thinking": "Product Thinking",
"tech-builds": "Tech Builds",
"business-experiments": "Business Experiments",
"systems-productivity": "Systems & Productivity"
}
},
"work": {
"title": "Work",
"subtitle": "Products, brands, and projects I've built",
"types": {
"all": "All",
"product": "Product",
"brand-system": "Brand System",
"open-source": "Open Source",
"creative-work": "Creative Work"
},
"problem": "The Problem",
"approach": "Approach",
"outcome": "Outcome",
"tech_stack": "Tech Stack",
"tools": "Tools Used"
}
}

83
src/messages/fa.json Normal file
View File

@@ -0,0 +1,83 @@
{
"nav": {
"home": "خانه",
"writing": "نوشته‌ها",
"work": "کارها",
"about": "درباره",
"resume": "رزومه",
"shop": "فروشگاه",
"contact": "تماس",
"uses": "ابزارها"
},
"hero": {
"taglines": [
"ساختن محصول، برند و سیستم از تهران",
"مدیرعامل NODE-Group · سازنده · استراتژیست",
"چیزهایی می‌سازم که کار می‌کنند — در کد، در بازار، در فرهنگ"
],
"bio": "علی تقوی هستم. سازنده‌ای از تهران که در تقاطع فناوری، برندینگ، بازاریابی و استراتژی محصول کار می‌کند.",
"cta_work": "کارهایم را ببینید",
"cta_writing": "نوشته‌هایم را بخوانید"
},
"sections": {
"selected_work": "نمونه کارها",
"building_now": "در حال ساختن",
"latest_writing": "آخرین نوشته‌ها",
"signal": "کانال تلگرام",
"signal_desc": "یادداشت‌های خام، ایده‌ها و آپدیت‌های روزانه",
"signal_cta": "عضو کانال شو"
},
"footer": {
"rights": "تمام حقوق محفوظ است",
"built_with": "ساخته شده با"
},
"common": {
"read_more": "ادامه بخوانید",
"view_project": "مشاهده پروژه",
"back": "بازگشت",
"loading": "در حال بارگذاری...",
"error": "خطایی رخ داد",
"all": "همه",
"search_placeholder": "جستجو...",
"no_results": "نتیجه‌ای یافت نشد",
"min_read": "دقیقه مطالعه",
"share": "اشتراک‌گذاری",
"copy_link": "کپی لینک",
"copied": "کپی شد!",
"table_of_contents": "فهرست مطالب",
"related_posts": "نوشته‌های مرتبط",
"live_demo": "نسخه زنده",
"source_code": "کد منبع"
},
"writing": {
"title": "نوشته‌ها",
"subtitle": "یادداشت‌ها، تحلیل‌ها و آموخته‌هایم از ساختن کسب‌وکار، محصول و برند",
"grid_view": "نمای شبکه",
"list_view": "نمای لیست",
"categories": {
"all": "همه",
"founder-notes": "یادداشت‌های بنیان‌گذار",
"marketing-branding": "بازاریابی و برندینگ",
"product-thinking": "تفکر محصول",
"tech-builds": "ساخت‌های فنی",
"business-experiments": "آزمایش‌های کسب‌وکار",
"systems-productivity": "سیستم‌ها و بهره‌وری"
}
},
"work": {
"title": "کارها",
"subtitle": "محصولات، برندها و پروژه‌هایی که ساخته‌ام",
"types": {
"all": "همه",
"product": "محصول",
"brand-system": "سیستم برند",
"open-source": "متن‌باز",
"creative-work": "اثر خلاقانه"
},
"problem": "مسئله",
"approach": "رویکرد",
"outcome": "نتیجه",
"tech_stack": "تکنولوژی‌ها",
"tools": "ابزارها"
}
}

8
src/proxy.ts Normal file
View File

@@ -0,0 +1,8 @@
import createMiddleware from "next-intl/middleware";
import { routing } from "./lib/i18n/routing";
export default createMiddleware(routing);
export const config = {
matcher: ["/((?!_next|_vercel|studio|api|.*\\..*).*)"],
};

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}