phase 1-2 done
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.github
|
||||
node_modules
|
||||
.next
|
||||
.env*.local
|
||||
.DS_Store
|
||||
README.md
|
||||
65
.github/workflows/deploy.yml
vendored
Normal file
65
.github/workflows/deploy.yml
vendored
Normal 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
42
.gitignore
vendored
Normal 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
41
Dockerfile
Normal 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
36
README.md
Normal 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
21
components.json
Normal 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
42
docker-compose.yml
Normal 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
18
eslint.config.mjs
Normal 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
15
next-sitemap.config.js
Normal 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
15
next.config.ts
Normal 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
102
nginx.conf
Normal 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
46
package.json
Normal 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
13774
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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
36
sanity/sanity.config.ts
Normal 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
25
sanity/schemas/author.ts
Normal 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" } },
|
||||
});
|
||||
14
sanity/schemas/category.ts
Normal file
14
sanity/schemas/category.ts
Normal 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
9
sanity/schemas/index.ts
Normal 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
56
sanity/schemas/post.ts
Normal 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
29
sanity/schemas/product.ts
Normal 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
51
sanity/schemas/project.ts
Normal 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" } },
|
||||
});
|
||||
27
sanity/schemas/siteSettings.ts
Normal file
27
sanity/schemas/siteSettings.ts
Normal 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
12
sanity/schemas/tag.ts
Normal 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" } },
|
||||
});
|
||||
21
sanity/schemas/timelineEvent.ts
Normal file
21
sanity/schemas/timelineEvent.ts
Normal 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" } },
|
||||
});
|
||||
29
sanity/schemas/usesItem.ts
Normal file
29
sanity/schemas/usesItem.ts
Normal 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" } },
|
||||
});
|
||||
9
src/app/[locale]/about/page.tsx
Normal file
9
src/app/[locale]/about/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/[locale]/contact/page.tsx
Normal file
9
src/app/[locale]/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
src/app/[locale]/layout.tsx
Normal file
96
src/app/[locale]/layout.tsx
Normal 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
22
src/app/[locale]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
src/app/[locale]/work/[slug]/page.tsx
Normal file
150
src/app/[locale]/work/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/[locale]/work/page.tsx
Normal file
35
src/app/[locale]/work/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
src/app/[locale]/writing/[slug]/page.tsx
Normal file
169
src/app/[locale]/writing/[slug]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/app/[locale]/writing/page.tsx
Normal file
38
src/app/[locale]/writing/page.tsx
Normal 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
90
src/app/api/og/route.tsx
Normal 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 }
|
||||
);
|
||||
}
|
||||
13
src/app/api/revalidate/route.ts
Normal file
13
src/app/api/revalidate/route.ts
Normal 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
45
src/app/api/rss/route.ts
Normal 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
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
89
src/app/globals.css
Normal file
89
src/app/globals.css
Normal 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
19
src/app/layout.tsx
Normal 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;
|
||||
}
|
||||
10
src/app/studio/[[...tool]]/page.tsx
Normal file
10
src/app/studio/[[...tool]]/page.tsx
Normal 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} />;
|
||||
}
|
||||
79
src/components/home/BuildingNow.tsx
Normal file
79
src/components/home/BuildingNow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
98
src/components/home/Hero.tsx
Normal file
98
src/components/home/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
src/components/home/LatestWriting.tsx
Normal file
83
src/components/home/LatestWriting.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/home/SignalCTA.tsx
Normal file
43
src/components/home/SignalCTA.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/layout/Footer.tsx
Normal file
74
src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/layout/Header.tsx
Normal file
69
src/components/layout/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/layout/LanguageSwitcher.tsx
Normal file
35
src/components/layout/LanguageSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/components/layout/MobileMenu.tsx
Normal file
66
src/components/layout/MobileMenu.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
src/components/shared/AnimatedSection.tsx
Normal file
28
src/components/shared/AnimatedSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/components/shared/SectionHeader.tsx
Normal file
21
src/components/shared/SectionHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/components/ui/badge.tsx
Normal file
33
src/components/ui/badge.tsx
Normal 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 };
|
||||
56
src/components/ui/button.tsx
Normal file
56
src/components/ui/button.tsx
Normal 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 };
|
||||
63
src/components/ui/card.tsx
Normal file
63
src/components/ui/card.tsx
Normal 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 };
|
||||
25
src/components/ui/separator.tsx
Normal file
25
src/components/ui/separator.tsx
Normal 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 };
|
||||
89
src/components/work/ProjectCard.tsx
Normal file
89
src/components/work/ProjectCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
src/components/work/WorkListClient.tsx
Normal file
49
src/components/work/WorkListClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
src/components/writing/CategoryFilter.tsx
Normal file
43
src/components/writing/CategoryFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
106
src/components/writing/PortableTextRenderer.tsx
Normal file
106
src/components/writing/PortableTextRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
89
src/components/writing/PostCard.tsx
Normal file
89
src/components/writing/PostCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/components/writing/ReadingProgress.tsx
Normal file
27
src/components/writing/ReadingProgress.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/writing/ShareButtons.tsx
Normal file
55
src/components/writing/ShareButtons.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
105
src/components/writing/WritingListClient.tsx
Normal file
105
src/components/writing/WritingListClient.tsx
Normal 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
13
src/lib/fonts.ts
Normal 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
13
src/lib/i18n/config.ts
Normal 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",
|
||||
};
|
||||
5
src/lib/i18n/navigation.ts
Normal file
5
src/lib/i18n/navigation.ts
Normal 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
15
src/lib/i18n/request.ts
Normal 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
8
src/lib/i18n/routing.ts
Normal 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
18
src/lib/sanity/client.ts
Normal 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
9
src/lib/sanity/image.ts
Normal 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
98
src/lib/sanity/queries.ts
Normal 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
63
src/lib/sanity/types.ts
Normal 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
34
src/lib/sanity/utils.ts
Normal 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
6
src/lib/utils.ts
Normal 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
83
src/messages/en.json
Normal 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
83
src/messages/fa.json
Normal 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
8
src/proxy.ts
Normal 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
34
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user