Compare commits

...

61 Commits

Author SHA1 Message Date
b64c6cf612 feat(admin): add collapsible desktop sidebar
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-15 21:53:39 +03:30
958400a8c1 feat(admin-dashboard): link top content lists 2026-06-15 21:50:42 +03:30
da8d82955e fix(admin-dashboard): refine filter reset controls 2026-06-15 21:46:20 +03:30
021bee9444 fix(admin-dashboard): prevent mobile chart overflow 2026-06-15 21:34:59 +03:30
ecd4a57da9 fix(admin-dashboard): align RTL chart axes
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-15 18:11:29 +03:30
f30d53df7e refactor(admin): simplify grouped navigation
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-15 17:43:17 +03:30
4edf8a0736 fix(admin-dashboard): tighten mobile layout 2026-06-15 17:40:31 +03:30
4fb44fcb4c fix(admin-dashboard): improve blog engagement visuals 2026-06-15 17:38:11 +03:30
268dd26d9a feat(admin-dashboard): add full result drilldown modals 2026-06-15 17:35:45 +03:30
6ba8f6ec8b feat(admin-dashboard): sync tabs and filters with URL 2026-06-15 17:32:03 +03:30
e3ddb733ee fix(admin-dashboard): polish filters and date pickers 2026-06-15 17:29:58 +03:30
9f07c0740d fix(admin-dashboard): make charts RTL-safe 2026-06-15 17:28:55 +03:30
83321c1d39 fix(admin): refresh analytics dashboard layout
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-15 16:18:13 +03:30
6c3a7ed5f4 feat(admin): add analytics dashboard UI
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-14 09:52:41 +03:30
5c15727516 feat(admin): wire analytics dashboard route 2026-06-14 09:52:14 +03:30
fc94ceb9f5 feat(ui): confirm destructive admin actions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-14 09:10:32 +03:30
4e24b96068 fix(admin): correct metadata option labels 2026-06-14 09:10:12 +03:30
a76a8a96ff feat(payments): add coupon management page
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-14 00:05:24 +03:30
ec38a4435e feat(events): add admin registration detail sidebar 2026-06-14 00:05:13 +03:30
9d05973a19 feat(events): redesign admin event editing 2026-06-14 00:05:01 +03:30
4e800a8bd9 feat(admin): group sidebar navigation 2026-06-14 00:04:43 +03:30
0e7bf49b61 feat(admin): add user and metadata management pages 2026-06-14 00:04:35 +03:30
9080b0caea feat(frontend): add async admin form foundations 2026-06-14 00:04:22 +03:30
25bd46ea2a fix(admin): confirm event deletion actions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-13 10:25:09 +03:30
9e5be244f0 fix(admin): debounce authorization user search 2026-06-13 10:16:40 +03:30
10992303de fix(profile): align activity item cards 2026-06-13 10:02:20 +03:30
053b742f89 feat(blog): show review feedback in admin
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-13 00:00:40 +03:30
489e46dd06 fix(admin): use icon blog review actions 2026-06-12 23:44:05 +03:30
25cbf53179 feat(blog): add codemirror markdown editor 2026-06-12 23:43:55 +03:30
cb8eeadba9 fix(admin): improve responsive UI styles of admin blog page 2026-06-12 22:02:40 +03:30
eb28a00abd feat(blog): add queued asset uploads 2026-06-12 21:35:29 +03:30
1e302d2aa2 fix(admin): polish mobile layout and actions 2026-06-12 21:35:17 +03:30
4611c8d63b fix(admin): align RTL management actions 2026-06-12 15:09:16 +03:30
971b709169 refactor(profile): move edit action to profile page 2026-06-12 15:09:04 +03:30
492bfd9918 feat(blog): redesign admin markdown editor 2026-06-12 15:08:53 +03:30
5fcc370611 feat(admin): add collapsible sidebar navigation 2026-06-12 15:08:43 +03:30
9051e32e5a feat(admin): add taxonomy and authorization pages 2026-06-12 15:08:31 +03:30
bced5dceb1 fix(blog): collapse inactive toc branches
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-12 11:21:19 +03:30
e06a4b1cf8 fix(blog): flatten nested comment replies 2026-06-12 11:21:11 +03:30
b953d78b19 fix(profile): wire blog activity data 2026-06-12 11:21:04 +03:30
5a9d36efa9 fix(profile): improve styling of profile page
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-11 22:24:15 +03:30
ed14ea9488 fix(blog): tune post title leading 2026-06-11 21:40:49 +03:30
0668fa2bb3 fix(navbar): polish logo and icon controls 2026-06-11 21:22:17 +03:30
da95b4ec99 fix(markdown): align article headings 2026-06-11 21:22:10 +03:30
53d989f730 feat(blog): wire comment moderation and writers 2026-06-11 21:22:03 +03:30
f424225abc feat(blog): refresh post detail layout 2026-06-11 21:21:52 +03:30
e89fcfb20e feat(blog): add rich listing filters 2026-06-11 21:21:43 +03:30
3ec931aabb fix(blog): add matching loading skeletons 2026-06-11 21:21:33 +03:30
7ddc6b158d feat(blog): redesign post detail experience
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-10 11:56:21 +03:30
0d01933f9d feat(blog): redesign post list cards 2026-06-10 11:56:09 +03:30
039158e0c4 feat(blog): add copyable highlighted code blocks 2026-06-10 11:55:56 +03:30
8e5096d192 feat(frontend): refine blog and profile experience
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-09 08:53:31 +03:30
8b1fc942cf fix(frontend): resolve published blog detail 404
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-08 22:36:27 +03:30
37b123838f fix(frontend): add blog admin preview route
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-08 22:05:48 +03:30
49dcb1dd1b feat(frontend): add blog editor and interactions
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-06-08 21:31:07 +03:30
f2b4cfce1a feat(frontend): rebuild auth around mobile-first flow
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-21 10:28:03 +03:30
66bb2fa107 feat(frontend): add mobile bottom nav and redesign profile
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-20 16:04:36 +03:30
18de81c173 F(frontend): add image lightbox and derivative fallbacks
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-20 14:26:49 +03:30
5711961b9b F(frontend): add route loading feedback
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled
2026-05-20 11:47:27 +03:30
f2d5b92b22 F(frontend): bundle Vazirmatn locally 2026-05-20 11:45:08 +03:30
f23108cda3 refactor(all): migrate from React to Next.js 2026-05-20 11:24:07 +03:30
152 changed files with 17351 additions and 5796 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
.git
.next
node_modules
npm-debug.log
dist

View File

@@ -1,2 +1,2 @@
VITE_API_BASE_URL=https://api.east-guilan-ce.ir
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8000
NEXT_PUBLIC_SITE_URL=http://localhost:8080

1
.gitignore vendored
View File

@@ -8,6 +8,7 @@ pnpm-debug.log*
lerna-debug.log*
node_modules
.next
dist
dist-ssr
.env

View File

@@ -1,43 +1,41 @@
# Frontend
## Stack
- Vite + React 18 with TypeScript.
- `@tanstack/react-query` for data fetching and caching.
- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS.
- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers.
- Next.js App Router with React 18 and TypeScript.
- `@tanstack/react-query` for client-side authenticated flows.
- Tailwind CSS and shadcn/ui components.
- `next-themes`, Sonner, and toast helpers for RTL UI and notifications.
## Environment
Copy `.env.sample` to `.env`.
Required variables:
- `NEXT_PUBLIC_API_BASE_URL`
- `NEXT_PUBLIC_SITE_URL`
## Development
### Install dependencies
```bash
npm install
npm run dev
```
### Configure API base URL
```bash
cp .env.sample .env
```
The local dev server runs on `http://localhost:8080`.
### Run dev server
```bash
npm run dev -- --host
```
### Production build
## Production build
```bash
npm run build
npm run start
```
The Vite build reads `VITE_API_BASE_URL` from `.env`.
The production runtime serves on port `3000` inside Docker. Dockerfiles live only in `guilan-ace-deployment`.
## Features
- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments.
- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion.
- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog).
## Routes
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
- Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
- Admin: `/admin/*`
## Testing & linting
## Validation
```bash
npm run lint
npm run build
```
JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy.

BIN
bun.lockb

Binary file not shown.

View File

@@ -1,11 +1,11 @@
import js from "@eslint/js";
import nextPlugin from "@next/eslint-plugin-next";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{ ignores: ["dist", ".next", "next-env.d.ts"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
@@ -14,12 +14,12 @@ export default tseslint.config(
globals: globals.browser,
},
plugins: {
"@next/next": nextPlugin,
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...nextPlugin.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
"@typescript-eslint/no-unused-vars": "off",
},
},

View File

@@ -1,22 +0,0 @@
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Vazirmatn:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<title>انجمن علمی کامپیوتر گیلان - Guilan ACE</title>
<meta name="description" content="انجمن علمی دانشجویی کامپیوتر دانشگاه گیلان" />
<meta name="author" content="Guilan ACE" />
<meta property="og:title" content="انجمن علمی کامپیوتر گیلان" />
<meta property="og:description" content="انجمن علمی دانشجویی کامپیوتر دانشگاه گیلان" />
<meta property="og:type" content="website" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

25
next.config.ts Normal file
View File

@@ -0,0 +1,25 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "api.east-guilan-ce.ir",
},
{
protocol: "http",
hostname: "127.0.0.1",
port: "8000",
},
{
protocol: "http",
hostname: "localhost",
port: "8000",
},
],
},
};
export default nextConfig;

View File

@@ -1,33 +0,0 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json;
# 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;
# Handle Next.js static export
location / {
try_files $uri $uri.html $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Disable access to hidden files
location ~ /\. {
deny all;
}
}

2762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,23 @@
{
"name": "vite_react_shadcn_ts",
"name": "guilan-ace-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"dev": "next dev --port 8080",
"build": "next build",
"lint": "eslint .",
"preview": "vite preview"
"start": "next start --port 3000"
},
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@hookform/resolvers": "^3.10.0",
"@lezer/highlight": "^1.2.3",
"@radix-ui/react-accordion": "^1.2.11",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
@@ -45,17 +51,22 @@
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"embla-carousel-react": "^8.6.0",
"html2canvas": "^1.4.1",
"input-otp": "^1.4.2",
"jspdf": "^2.5.1",
"lucide-react": "^0.462.0",
"next": "^15.4.6",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-date-object": "^2.1.9",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-helmet-async": "^2.0.5",
"react-hook-form": "^7.61.1",
"react-markdown": "^9.0.3",
"react-multi-date-picker": "^4.5.2",
"react-qr-code": "^2.0.11",
"react-resizable-panels": "^2.1.9",
"react-router-dom": "^6.30.1",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^2.15.4",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
@@ -64,28 +75,24 @@
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^0.9.9",
"zod": "^3.25.76",
"react-qr-code": "^2.0.11",
"jspdf": "^2.5.1",
"html2canvas": "^1.4.1"
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^9.32.0",
"@next/eslint-plugin-next": "^16.2.6",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.16.5",
"@types/react": "^18.3.26",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react-swc": "^3.11.0",
"@types/react-syntax-highlighter": "^15.5.13",
"autoprefixer": "^10.4.21",
"eslint": "^9.32.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^15.15.0",
"lovable-tagger": "^1.1.10",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"typescript-eslint": "^8.38.0",
"vite": "^5.4.19"
"typescript-eslint": "^8.38.0"
}
}

View File

@@ -1,14 +0,0 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

View File

@@ -1,72 +0,0 @@
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { HelmetProvider } from "react-helmet-async";
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import Layout from "@/components/Layout";
import { AuthProvider } from "@/contexts/AuthContext";
import AdminLayout from "@/pages/AdminLayout";
import AdminUsers from "@/pages/AdminUsers";
import AdminEvents from "@/pages/AdminEvents";
import AdminEventEdit from "@/pages/AdminEventEdit";
import AdminEventDetail from "@/pages/AdminEventDetail";
import AboutUs from "@/pages/AboutUs";
import Auth from "@/pages/Auth";
import Blog from "@/pages/Blog";
import EventDetail from "@/pages/EventDetail";
import EventFreeSuccessPage from "@/pages/EventFreeSuccessPage";
import Events from "@/pages/Events";
import Home from "@/pages/Home";
import Logout from "@/pages/Logout";
import NotFound from "@/pages/NotFound";
import PaymentResult from "@/pages/PaymentResult";
import Profile from "@/pages/Profile";
import ResetPasswordConfirm from "@/pages/ResetPasswordConfirm";
import ResetPasswordRequest from "@/pages/ResetPasswordRequest";
import VerifyEmail from "@/pages/VerifyEmail";
const queryClient = new QueryClient();
const App = () => (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<TooltipProvider>
<Toaster />
<Sonner />
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="auth" element={<Auth />} />
<Route path="logout" element={<Logout />} />
<Route path="profile" element={<Profile />} />
<Route path="blog" element={<Blog />} />
<Route path="events" element={<Events />} />
<Route path="events/:slug" element={<EventDetail />} />
<Route path="events/:slug/success" element={<EventFreeSuccessPage />} />
<Route path="payments/result" element={<PaymentResult />} />
<Route path="verify-email/:token" element={<VerifyEmail />} />
<Route path="reset-password" element={<ResetPasswordRequest />} />
<Route path="reset-password/:token" element={<ResetPasswordConfirm />} />
<Route path="/about" element={<AboutUs />} />
<Route path="/admin" element={<AdminLayout />}>
<Route index element={<Navigate to="/admin/users" replace />} />
<Route path="users" element={<AdminUsers />} />
<Route path="events" element={<AdminEvents />} />
<Route path="events/:id" element={<AdminEventDetail />} />
<Route path="events/:id/edit" element={<AdminEventEdit />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
</HelmetProvider>
</TooltipProvider>
</AuthProvider>
</QueryClientProvider>
);
export default App;

65
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import type { Metadata } from "next";
import AboutUs from "@/views/AboutUs";
import { siteUrl } from "@/lib/site";
const title = "درباره ما | انجمن علمی کامپیوتر شرق گیلان";
const description =
"آشنایی با تاریخچه، مأموریت‌ها و دستاوردهای انجمن علمی کامپیوتر شرق گیلان و راه‌های مشارکت دانشجویان در برنامه‌های انجمن.";
export const metadata: Metadata = {
title,
description,
keywords: [
"انجمن علمی کامپیوتر شرق گیلان",
"دانشگاه گیلان",
"انجمن علمی دانشجویی",
"رویدادهای فناوری",
],
alternates: { canonical: "/about" },
openGraph: {
title,
description,
url: `${siteUrl}/about`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${siteUrl}/favicon.ico`],
},
};
const structuredData = {
"@context": "https://schema.org",
"@type": "AboutPage",
name: title,
description,
url: `${siteUrl}/about`,
mainEntity: {
"@type": "Organization",
name: "انجمن علمی کامپیوتر شرق گیلان",
url: siteUrl,
logo: `${siteUrl}/favicon.ico`,
sameAs: [
"https://instagram.com/guilance.ir",
"https://t.me/guilance",
"https://t.me/guilancea",
],
},
};
export default function AboutPage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<AboutUs />
</>
);
}

View File

@@ -0,0 +1,5 @@
import AdminAuthorizations from "@/views/AdminAuthorizations";
export default function AdminAuthorizationsPage() {
return <AdminAuthorizations />;
}

View File

@@ -0,0 +1,8 @@
import AdminBlogAssets from "@/views/AdminBlogAssets";
type Params = Promise<{ id: string }>;
export default async function AdminBlogAssetsPage({ params }: { params: Params }) {
const { id } = await params;
return <AdminBlogAssets postId={Number(id)} />;
}

View File

@@ -0,0 +1,8 @@
import AdminBlogEditor from "@/views/AdminBlogEditor";
type Params = Promise<{ id: string }>;
export default async function AdminBlogEditorPage({ params }: { params: Params }) {
const { id } = await params;
return <AdminBlogEditor postId={id === "new" ? null : Number(id)} />;
}

View File

@@ -0,0 +1,8 @@
import AdminBlogPreview from "@/views/AdminBlogPreview";
type Params = Promise<{ id: string }>;
export default async function AdminBlogPreviewPage({ params }: { params: Params }) {
const { id } = await params;
return <AdminBlogPreview postId={Number(id)} />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogCategories from "@/views/AdminBlogCategories";
export default function AdminBlogCategoriesPage() {
return <AdminBlogCategories />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlog from "@/views/AdminBlog";
export default function AdminBlogPage() {
return <AdminBlog />;
}

View File

@@ -0,0 +1,5 @@
import AdminBlogTags from "@/views/AdminBlogTags";
export default function AdminBlogTagsPage() {
return <AdminBlogTags />;
}

View File

@@ -0,0 +1,5 @@
import AdminCoupons from "@/views/AdminCoupons";
export default function AdminCouponsRoute() {
return <AdminCoupons />;
}

View File

@@ -0,0 +1,5 @@
import AdminDashboard from "@/views/AdminDashboard";
export default function AdminDashboardPage() {
return <AdminDashboard />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEventEdit from "@/views/AdminEventEdit";
export const metadata: Metadata = {
title: "ویرایش رویداد",
robots: { index: false, follow: false },
};
export default function AdminEventEditPage() {
return <AdminEventEdit />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEventDetail from "@/views/AdminEventDetail";
export const metadata: Metadata = {
title: "جزئیات رویداد",
robots: { index: false, follow: false },
};
export default function AdminEventDetailPage() {
return <AdminEventDetail />;
}

View File

@@ -0,0 +1,5 @@
import AdminEventForm from "@/views/AdminEventForm";
export default function AdminEventCreateRoute() {
return <AdminEventForm mode="create" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminEvents from "@/views/AdminEvents";
export const metadata: Metadata = {
title: "مدیریت رویدادها",
robots: { index: false, follow: false },
};
export default function AdminEventsPage() {
return <AdminEvents />;
}

10
src/app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,10 @@
import type { ReactNode } from "react";
import AdminLayout from "@/views/AdminLayout";
export default function AdminSectionLayout({
children,
}: {
children: ReactNode;
}) {
return <AdminLayout>{children}</AdminLayout>;
}

View File

@@ -0,0 +1,5 @@
import AdminMetaOptions from "@/views/AdminMetaOptions";
export default function AdminMajorsRoute() {
return <AdminMetaOptions kind="majors" />;
}

5
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AdminPage() {
redirect("/admin/dashboard");
}

View File

@@ -0,0 +1,5 @@
import AdminMetaOptions from "@/views/AdminMetaOptions";
export default function AdminUniversitiesRoute() {
return <AdminMetaOptions kind="universities" />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import AdminUsers from "@/views/AdminUsers";
export const metadata: Metadata = {
title: "مدیریت کاربران",
robots: { index: false, follow: false },
};
export default function AdminUsersPage() {
return <AdminUsers />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import GoogleAuthCallback from "@/views/GoogleAuthCallback";
export const metadata: Metadata = {
title: "ادامه ورود با گوگل",
robots: { index: false, follow: false },
};
export default function GoogleAuthCallbackPage() {
return <GoogleAuthCallback />;
}

11
src/app/auth/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Auth from "@/views/Auth";
export const metadata: Metadata = {
title: "ورود / ثبت‌نام",
robots: { index: false, follow: false },
};
export default function AuthPage() {
return <Auth />;
}

View File

@@ -0,0 +1,5 @@
import { BlogDetailPageLoading } from "@/components/page-loading";
export default function Loading() {
return <BlogDetailPageLoading />;
}

View File

@@ -0,0 +1,329 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { CalendarDays, Clock3, Hash, ListTree } from "lucide-react";
import BlogPostActions from "@/components/BlogPostActions";
import BlogPostInteractions from "@/components/BlogPostInteractions";
import BlogTableOfContents from "@/components/BlogTableOfContents";
import BlogThumbnail from "@/components/BlogThumbnail";
import Markdown from "@/components/Markdown";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Link } from "@/lib/router";
import { blogPostPath, blogPostUrl, normalizeBlogSlugParam } from "@/lib/blog-routes";
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
import { PublicApiError, getPublicPost, getRecommendedPosts } from "@/lib/public-api";
import { apiBaseUrl, siteUrl, toAbsoluteUrl } from "@/lib/site";
import type * as Types from "@/lib/types";
import { formatJalaliDate, getBlogCardImageUrl, getBlogHeroImageUrl, toPersianDigits } from "@/lib/utils";
type Params = Promise<{ slug: string }>;
type Writer = NonNullable<Types.PostListSchema["writers"]>[number];
export const dynamic = "force-dynamic";
function cleanText(value?: string | null) {
if (!value) return "";
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
function personName(person: { first_name: string; last_name: string; username: string }) {
return [person.first_name, person.last_name].filter(Boolean).join(" ") || person.username;
}
async function loadPost(slug: string) {
try {
return await getPublicPost(slug);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
throw error;
}
}
async function loadRecommended(slug: string) {
try {
return await getRecommendedPosts(slug, 3);
} catch {
return [];
}
}
function Topics({ tags }: { tags: Types.PostListSchema["tags"] }) {
if (!tags.length) {
return null;
}
return (
<div className="flex flex-wrap items-center justify-start gap-2" aria-label="موضوعات نوشته">
{tags.map((tag) => (
<Link key={tag.id} to={`/blog?tag=${encodeURIComponent(tag.slug)}`}>
<Badge variant="outline" className="rounded-full px-3 py-1 transition bg-slate-200 dark:bg-slate-600 hover:border-primary hover:text-primary">
<Hash className="h-3 w-3 text-primary" />
{tag.name}
</Badge>
</Link>
))}
</div>
);
}
function Breadcrumbs({ post }: { post: Types.PostDetailSchema }) {
const crumbs = post.category_path || [];
return (
<nav className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground" aria-label="مسیر نوشته">
<Link to="/blog" className="transition hover:text-primary">
بلاگ
</Link>
{crumbs.map((category) => (
<span key={category.id} className="flex items-center gap-2">
<span>/</span>
<Link to={`/blog?category=${encodeURIComponent(category.slug)}`} className="transition hover:text-primary">
{category.name}
</Link>
</span>
))}
</nav>
);
}
function WriterCards({ post }: { post: Types.PostDetailSchema }) {
const writers = post.writers?.length ? post.writers : [post.author];
return (
<section className="mt-8 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
{/* <div className="mb-4 text-right">
<p className="text-sm font-medium text-primary">نویسندگان</p>
</div> */}
<h2 className="mb-4 text-2xl font-bold">درباره نویسندگان این مقاله</h2>
<div className="grid gap-4 md:grid-cols-2">
{writers.map((writer: Writer) => {
const image = writer.profile_picture_preview_url || writer.profile_picture_thumbnail_url || writer.profile_picture;
return (
<Link
key={writer.id}
to={`/blog?author=${encodeURIComponent(writer.username)}`}
className="flex items-start gap-4 rounded-3xl border border-border/70 bg-background/80 p-4 text-right transition hover:-translate-y-0.5 hover:border-primary/50 hover:shadow-lg"
>
<Avatar className="h-14 w-14">
<AvatarImage src={image || undefined} alt={personName(writer)} />
<AvatarFallback>{personName(writer)[0] || "ن"}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h3 className="font-bold">{personName(writer)}</h3>
<p className="mt-2 line-clamp-4 text-sm leading-7 text-muted-foreground">
{writer.bio || "توضیحی برای این نویسنده ثبت نشده است."}
</p>
</div>
</Link>
);
})}
</div>
</section>
);
}
function RecommendedPosts({ posts }: { posts: Types.PostListSchema[] }) {
if (!posts.length) return null;
return (
<section className="mt-10 rounded-[2rem] border border-border/70 bg-card/90 p-5 shadow-sm">
<h2 className="mb-4 text-2xl font-bold">مقالات پیشنهادی</h2>
<div className="grid gap-4 md:grid-cols-3">
{posts.map((post) => (
<Link
key={post.id}
to={blogPostPath(post.slug)}
className="group overflow-hidden rounded-3xl border border-border/70 bg-background transition hover:-translate-y-1 hover:shadow-lg"
>
<BlogThumbnail
post={post}
imageUrl={toAbsoluteUrl(getBlogCardImageUrl(post), apiBaseUrl)}
className="aspect-[16/10]"
/>
<div className="space-y-2 p-4 text-right">
<h3 className="line-clamp-2 font-semibold leading-7 group-hover:text-primary">{post.title}</h3>
<time className="text-xs text-muted-foreground" dateTime={post.published_at || post.created_at}>
{formatJalaliDate(post.published_at || post.created_at)}
</time>
</div>
</Link>
))}
</div>
</section>
);
}
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const post = await loadPost(normalizeBlogSlugParam(slug));
const description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaTitle = post.seo_title || post.og_title || post.title;
const metaDescription = post.seo_description || post.og_description || description;
const canonical = post.canonical_url || blogPostPath(post.slug);
const image = toAbsoluteUrl(
post.og_image_url || getBlogHeroImageUrl(post),
apiBaseUrl,
) ?? `${siteUrl}/favicon.ico`;
return {
title: metaTitle,
description: metaDescription,
alternates: { canonical },
robots: post.noindex ? { index: false, follow: true } : undefined,
openGraph: {
title: post.og_title || metaTitle,
description: post.og_description || metaDescription,
url: blogPostUrl(siteUrl, post.slug),
siteName: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
type: "article",
images: [image],
locale: "fa_IR",
publishedTime: post.published_at || post.created_at,
modifiedTime: post.updated_at,
},
twitter: {
card: "summary_large_image",
title: post.og_title || metaTitle,
description: post.og_description || metaDescription,
images: [image],
},
};
}
export default async function BlogDetailPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const post = await loadPost(normalizeBlogSlugParam(slug));
const recommendedPosts = await loadRecommended(post.slug);
const headings = extractMarkdownHeadings(post.content);
const description = cleanText(post.excerpt || post.content).slice(0, 160);
const metaDescription = post.seo_description || post.og_description || description;
const coverImage = toAbsoluteUrl(getBlogHeroImageUrl(post), apiBaseUrl);
const seoImage = toAbsoluteUrl(post.og_image_url || getBlogHeroImageUrl(post), apiBaseUrl) ?? `${siteUrl}/favicon.ico`;
const writers = post.writers?.length ? post.writers : [post.author];
const structuredData = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
description: metaDescription,
image: [seoImage],
datePublished: post.published_at || post.created_at,
dateModified: post.updated_at,
url: blogPostUrl(siteUrl, post.slug),
author: writers.map((writer) => ({
"@type": "Person",
name: personName(writer),
})),
publisher: {
"@type": "Organization",
name: "انجمن علمی مهندسی کامپیوتر شرق گیلان",
logo: {
"@type": "ImageObject",
url: `${siteUrl}/favicon.ico`,
},
},
keywords: post.tags.map((tag) => tag.name).join(", "),
};
return (
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<div className="container mx-auto px-4 py-8">
<div className="gap-8 xl:flex xl:items-start">
<aside className="sticky top-24 max-h-[calc(100vh-7rem)] space-y-4 overflow-y-auto pr-1 hidden w-72 shrink-0 xl:block">
<section className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm backdrop-blur">
<h2 className="mb-3 flex items-center justify-start gap-2 text-right font-bold">
<ListTree className="h-4 w-4 text-primary" />
فهرست محتوا
</h2>
<BlogTableOfContents headings={headings} />
</section>
</aside>
<main className="min-w-0 flex-1">
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95">
<header className="space-y-6 p-5 text-right md:p-8">
<Breadcrumbs post={post} />
<div className="space-y-4">
<h1 className="text-3xl font-black leading-[1.35] tracking-tight md:text-5xl md:leading-[1.45]">
{post.title}
</h1>
{post.excerpt ? (
<p className="text-base leading-8 text-muted-foreground md:text-lg">
{post.excerpt}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-3 text-sm text-muted-foreground">
<span className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1">
<Clock3 className="h-4 w-4 text-primary" />
{toPersianDigits(post.reading_time ?? 1)} دقیقه مطالعه
</span>
<time
className="inline-flex items-center gap-2 rounded-full border border-border/70 bg-background/70 px-3 py-1"
dateTime={post.published_at || post.created_at}
>
<CalendarDays className="h-4 w-4 text-primary" />
{formatJalaliDate(post.published_at || post.created_at)}
</time>
</div>
</header>
<div className="px-5 md:px-8">
<BlogThumbnail
post={post}
imageUrl={coverImage}
className="aspect-[16/9] rounded-[2rem]"
priority
/>
</div>
<div className="space-y-5 p-5 md:p-8 xl:hidden">
<section className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
<h2 className="mb-3 flex items-center justify-start gap-2 text-right font-bold">
<ListTree className="h-4 w-4 text-primary" />
فهرست محتوا
</h2>
<BlogTableOfContents headings={headings} />
</section>
</div>
<div className="space-y-8 px-5 pb-8 pt-6 md:px-8 md:pb-10">
<Markdown content={post.content} justify size="base" className="mx-auto max-w-4xl" />
<div className="mx-auto max-w-4xl">
<Topics tags={post.tags} />
</div>
<BlogPostActions
slug={post.slug}
initialLikes={post.likes_count ?? 0}
initialSaves={post.saves_count ?? 0}
/>
</div>
</article>
<WriterCards post={post} />
<RecommendedPosts posts={recommendedPosts} />
<BlogPostInteractions
slug={post.slug}
initialComments={post.comments_count ?? 0}
/>
</main>
</div>
</div>
</div>
);
}

5
src/app/blog/loading.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { BlogListingPageLoading } from "@/components/page-loading";
export default function Loading() {
return <BlogListingPageLoading />;
}

73
src/app/blog/page.tsx Normal file
View File

@@ -0,0 +1,73 @@
import type { Metadata } from "next";
import Blog from "@/views/Blog";
import { getBlogBanners, getBlogFilters, getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function firstString(value?: string | string[]) {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
function stringList(value?: string | string[]) {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values.flatMap((item) => item.split(",")).map((item) => item.trim()).filter(Boolean);
}
export async function generateMetadata({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی وبلاگ: ${search}` : "وبلاگ";
const description = search
? `نتایج جست‌وجوی مقالات برای ${search} در وبلاگ انجمن علمی کامپیوتر شرق گیلان.`
: "مقالات، نوشته‌ها و محتوای آموزشی انجمن علمی کامپیوتر شرق گیلان.";
return {
title,
description,
alternates: { canonical: search ? `/blog?search=${encodeURIComponent(search)}` : "/blog" },
robots: search ? { index: false, follow: true } : undefined,
openGraph: {
title,
description,
url: search ? `${siteUrl}/blog?search=${encodeURIComponent(search)}` : `${siteUrl}/blog`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
};
}
export default async function BlogPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const category = firstString(resolved.category).trim();
const tags = stringList(resolved.tag);
const authors = stringList(resolved.author);
const [posts, banners, filters] = await Promise.all([
getPublicPosts({ search: search || undefined, category: category || undefined, tag: tags, author: authors }),
getBlogBanners().catch(() => []),
getBlogFilters().catch(() => ({ categories: [], tags: [], authors: [] })),
]);
return (
<Blog
initialPosts={posts}
initialSearch={search}
initialCategory={category}
initialTags={tags}
initialAuthors={authors}
banners={banners}
filters={filters}
/>
);
}

View File

@@ -0,0 +1,5 @@
import { DetailPageLoading } from "@/components/page-loading";
export default function Loading() {
return <DetailPageLoading />;
}

View File

@@ -0,0 +1,115 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import EventDetail from "@/views/EventDetail";
import { PublicApiError, getPublicEventBySlug } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
import { getEventSeoImageUrl } from "@/lib/utils";
type Params = Promise<{ slug: string }>;
function cleanText(value?: string | null) {
if (!value) return "";
return value.replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
async function loadEvent(slug: string) {
try {
return await getPublicEventBySlug(slug);
} catch (error) {
if (error instanceof PublicApiError && error.status === 404) {
notFound();
}
throw error;
}
}
export async function generateMetadata({
params,
}: {
params: Params;
}): Promise<Metadata> {
const { slug } = await params;
const event = await loadEvent(slug);
const description = cleanText(event.description).slice(0, 160);
const image = getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`;
return {
title: event.title,
description,
alternates: { canonical: `/events/${event.slug}` },
robots: event.status === "draft" ? { index: false, follow: false } : undefined,
openGraph: {
title: event.title,
description,
url: `${siteUrl}/events/${event.slug}`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [image],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title: event.title,
description,
images: [image],
},
};
}
export default async function EventDetailPage({
params,
}: {
params: Params;
}) {
const { slug } = await params;
const event = await loadEvent(slug);
const description = cleanText(event.description).slice(0, 160);
const structuredData = {
"@context": "https://schema.org",
"@type": "Event",
name: event.title,
description,
startDate: event.start_time,
endDate: event.end_time,
eventStatus:
event.status === "completed"
? "https://schema.org/EventCompleted"
: event.status === "cancelled"
? "https://schema.org/EventCancelled"
: "https://schema.org/EventScheduled",
eventAttendanceMode:
event.event_type === "online"
? "https://schema.org/OnlineEventAttendanceMode"
: event.event_type === "on_site"
? "https://schema.org/OfflineEventAttendanceMode"
: "https://schema.org/MixedEventAttendanceMode",
image: [getEventSeoImageUrl(event) || `${siteUrl}/favicon.ico`],
url: `${siteUrl}/events/${event.slug}`,
organizer: {
"@type": "Organization",
name: "انجمن علمی کامپیوتر شرق گیلان",
url: siteUrl,
},
offers: {
"@type": "Offer",
url: `${siteUrl}/events/${event.slug}`,
priceCurrency: "IRR",
price: String(event.price ?? 0),
availability:
(event.capacity ?? 0) > (event.registration_count ?? 0)
? "https://schema.org/InStock"
: "https://schema.org/SoldOut",
},
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<EventDetail initialEvent={event} />
</>
);
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import EventFreeSuccessPage from "@/views/EventFreeSuccessPage";
export const metadata: Metadata = {
title: "نتیجه ثبت‌نام رویداد",
robots: { index: false, follow: false },
};
export default function EventSuccessPage() {
return <EventFreeSuccessPage />;
}

View File

@@ -0,0 +1,5 @@
import { ListingPageLoading } from "@/components/page-loading";
export default function Loading() {
return <ListingPageLoading />;
}

51
src/app/events/page.tsx Normal file
View File

@@ -0,0 +1,51 @@
import type { Metadata } from "next";
import Events from "@/views/Events";
import { getPublicEvents } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
function firstString(value?: string | string[]) {
return Array.isArray(value) ? (value[0] ?? "") : (value ?? "");
}
export async function generateMetadata({
searchParams,
}: {
searchParams: SearchParams;
}): Promise<Metadata> {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const title = search ? `جست‌وجوی رویدادها: ${search}` : "رویدادها";
const description = search
? `نتایج جست‌وجوی رویدادها برای ${search} در انجمن علمی کامپیوتر شرق گیلان.`
: "رویدادهای جاری و گذشته انجمن علمی کامپیوتر شرق گیلان، شامل کارگاه‌ها، مسابقات و برنامه‌های آموزشی.";
return {
title,
description,
alternates: { canonical: search ? `/events?search=${encodeURIComponent(search)}` : "/events" },
robots: search ? { index: false, follow: true } : undefined,
openGraph: {
title,
description,
url: search ? `${siteUrl}/events?search=${encodeURIComponent(search)}` : `${siteUrl}/events`,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
};
}
export default async function EventsPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
const resolved = await searchParams;
const search = firstString(resolved.search).trim();
const events = await getPublicEvents({ search: search || undefined });
return <Events initialEvents={events} initialSearch={search} />;
}

Binary file not shown.

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

@@ -0,0 +1,52 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Suspense } from "react";
import MobileBottomNav from "@/components/MobileBottomNav";
import Navbar from "@/components/Navbar";
import Footer from "@/components/Footer";
import Providers from "@/components/providers";
import RouteProgress from "@/components/RouteProgress";
import { siteUrl } from "@/lib/site";
import "../index.css";
const vazirmatn = localFont({
src: "./fonts/Vazirmatn-variable.woff2",
display: "swap",
weight: "100 900",
variable: "--font-vazirmatn",
fallback: ["Tahoma", "Arial", "sans-serif"],
});
export const metadata: Metadata = {
metadataBase: new URL(siteUrl),
title: {
default: "انجمن علمی کامپیوتر شرق گیلان",
template: "%s | انجمن علمی کامپیوتر شرق گیلان",
},
description:
"انجمن علمی کامپیوتر شرق گیلان، برگزارکننده رویدادها، کارگاه‌ها و محتوای آموزشی برای دانشجویان و علاقه‌مندان فناوری.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fa" dir="rtl" suppressHydrationWarning>
<body className={`${vazirmatn.variable} font-sans antialiased`}>
<Providers>
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
<Navbar />
<div className="min-h-screen pb-28 md:pb-0">
{children}
<Footer />
</div>
<MobileBottomNav />
</Providers>
</body>
</html>
);
}

11
src/app/logout/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Logout from "@/views/Logout";
export const metadata: Metadata = {
title: "خروج",
robots: { index: false, follow: false },
};
export default function LogoutPage() {
return <Logout />;
}

19
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { Button } from "@/components/ui/button";
import { Link } from "@/lib/router";
export default function NotFound() {
return (
<div
className="min-h-[70vh] flex flex-col items-center justify-center gap-4 px-4 text-center"
dir="rtl"
>
<h1 className="text-4xl font-bold">صفحه پیدا نشد</h1>
<p className="text-muted-foreground">
آدرسی که وارد کردهاید وجود ندارد یا جابهجا شده است.
</p>
<Button asChild>
<Link to="/">بازگشت به خانه</Link>
</Button>
</div>
);
}

56
src/app/page.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { Metadata } from "next";
import Home from "@/views/Home";
import { siteUrl } from "@/lib/site";
const title = "انجمن علمی کامپیوتر دانشگاه گیلان";
const description =
"با ما همراه شوید و در دنیای مهندسی و علوم کامپیوتر پیشرفت کنید. رویدادها، محتوای آموزشی و جامعه‌ای پویا برای رشد شما فراهم است.";
export const metadata: Metadata = {
title,
description,
alternates: { canonical: "/" },
openGraph: {
title,
description,
url: siteUrl,
siteName: "انجمن علمی کامپیوتر شرق گیلان",
type: "website",
images: [`${siteUrl}/favicon.ico`],
locale: "fa_IR",
},
twitter: {
card: "summary_large_image",
title,
description,
images: [`${siteUrl}/favicon.ico`],
},
};
const structuredData = {
"@context": "https://schema.org",
"@type": "Organization",
name: title,
url: siteUrl,
sameAs: [`${siteUrl}/blog`, `${siteUrl}/events`],
description,
logo: `${siteUrl}/favicon.ico`,
contactPoint: {
"@type": "ContactPoint",
email: "admin@east-guilan-ce.ir",
contactType: "customer support",
availableLanguage: ["fa", "en"],
},
};
export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<Home />
</>
);
}

View File

@@ -0,0 +1,16 @@
import { Suspense } from "react";
import type { Metadata } from "next";
import PaymentResult from "@/views/PaymentResult";
export const metadata: Metadata = {
title: "نتیجه پرداخت",
robots: { index: false, follow: false },
};
export default function PaymentResultPage() {
return (
<Suspense fallback={null}>
<PaymentResult />
</Suspense>
);
}

11
src/app/profile/page.tsx Normal file
View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import Profile from "@/views/Profile";
export const metadata: Metadata = {
title: "پروفایل",
robots: { index: false, follow: false },
};
export default function ProfilePage() {
return <Profile />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ResetPasswordConfirm from "@/views/ResetPasswordConfirm";
export const metadata: Metadata = {
title: "تعیین رمز جدید",
robots: { index: false, follow: false },
};
export default function ResetPasswordConfirmPage() {
return <ResetPasswordConfirm />;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import ResetPasswordRequest from "@/views/ResetPasswordRequest";
export const metadata: Metadata = {
title: "بازیابی رمز عبور",
robots: { index: false, follow: false },
};
export default function ResetPasswordPage() {
return <ResetPasswordRequest />;
}

23
src/app/robots.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
import { siteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: ["/", "/about", "/blog", "/events"],
disallow: [
"/admin/",
"/auth",
"/logout",
"/profile",
"/payments/",
"/reset-password",
"/verify-email/",
],
},
],
sitemap: `${siteUrl}/sitemap.xml`,
};
}

55
src/app/sitemap.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { MetadataRoute } from "next";
import { blogPostUrl } from "@/lib/blog-routes";
import { getPublicEvents, getPublicPosts } from "@/lib/public-api";
import { siteUrl } from "@/lib/site";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const routes: MetadataRoute.Sitemap = [
{
url: siteUrl,
changeFrequency: "weekly",
priority: 1,
},
{
url: `${siteUrl}/about`,
changeFrequency: "monthly",
priority: 0.7,
},
{
url: `${siteUrl}/blog`,
changeFrequency: "weekly",
priority: 0.8,
},
{
url: `${siteUrl}/events`,
changeFrequency: "daily",
priority: 0.9,
},
];
try {
const [posts, events] = await Promise.all([
getPublicPosts({ limit: 200 }),
getPublicEvents({ limit: 200 }),
]);
routes.push(
...posts.map((post) => ({
url: blogPostUrl(siteUrl, post.slug),
lastModified: new Date(post.published_at || post.created_at),
changeFrequency: "monthly" as const,
priority: 0.7,
})),
...events.map((event) => ({
url: `${siteUrl}/events/${event.slug}`,
lastModified: new Date(event.created_at),
changeFrequency: "weekly" as const,
priority: 0.8,
})),
);
} catch {
return routes;
}
return routes;
}

View File

@@ -0,0 +1,11 @@
import type { Metadata } from "next";
import VerifyEmail from "@/views/VerifyEmail";
export const metadata: Metadata = {
title: "تأیید ایمیل",
robots: { index: false, follow: false },
};
export default function VerifyEmailPage() {
return <VerifyEmail />;
}

View File

@@ -0,0 +1,96 @@
"use client";
import * as React from "react";
import DateObject from "react-date-object";
import persian from "react-date-object/calendars/persian";
import persian_fa from "react-date-object/locales/persian_fa";
import DatePicker from "react-multi-date-picker";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
type AdminDateTimeFieldProps = {
label: string;
value?: string | null;
onChange: (value: string | null) => void;
required?: boolean;
disabled?: boolean;
};
function splitDateTime(value?: string | null) {
if (!value) {
return { date: null as DateObject | null, time: "" };
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return { date: null, time: "" };
}
return {
date: new DateObject({ date, calendar: persian, locale: persian_fa }),
time: `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`,
};
}
function combineDateTime(date: DateObject | null, time: string) {
if (!date || !time || !/^\d{2}:\d{2}$/.test(time)) return null;
const gregorian = date.toDate();
const [hours, minutes] = time.split(":").map(Number);
gregorian.setHours(hours, minutes, 0, 0);
return gregorian.toISOString();
}
export default function AdminDateTimeField({
label,
value,
onChange,
required,
disabled,
}: AdminDateTimeFieldProps) {
const initial = React.useMemo(() => splitDateTime(value), [value]);
const [date, setDate] = React.useState<DateObject | null>(initial.date);
const [time, setTime] = React.useState(initial.time);
React.useEffect(() => {
setDate(initial.date);
setTime(initial.time);
}, [initial.date, initial.time]);
const emitChange = (nextDate: DateObject | null, nextTime: string) => {
onChange(combineDateTime(nextDate, nextTime));
};
return (
<div className="space-y-2">
<Label>
{label}
{required ? <span className="text-destructive"> *</span> : null}
</Label>
<div className="grid gap-2 sm:grid-cols-[1fr_120px]">
<DatePicker
value={date}
onChange={(next) => {
const nextDate = next instanceof DateObject ? next : null;
setDate(nextDate);
emitChange(nextDate, time);
}}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
disabled={disabled}
inputClass="h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-right ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder="تاریخ"
containerClassName="w-full"
/>
<Input
dir="ltr"
type="time"
value={time}
disabled={disabled}
onChange={(event) => {
setTime(event.target.value);
emitChange(date, event.target.value);
}}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
export type AsyncComboboxOption = {
value: string;
label: string;
description?: string;
};
type AsyncSearchableComboboxProps = {
value?: string | null;
onChange: (value: string | null) => void;
loadOptions: (params: { search: string; limit: number; offset: number }) => Promise<{
count: number;
results: AsyncComboboxOption[];
}>;
placeholder?: string;
searchPlaceholder?: string;
emptyText?: string;
disabled?: boolean;
allowClear?: boolean;
pageSize?: number;
className?: string;
};
export default function AsyncSearchableCombobox({
value,
onChange,
loadOptions,
placeholder = "انتخاب کنید",
searchPlaceholder = "جستجو...",
emptyText = "موردی پیدا نشد.",
disabled = false,
allowClear = true,
pageSize = 20,
className,
}: AsyncSearchableComboboxProps) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState("");
const [debouncedSearch, setDebouncedSearch] = React.useState("");
const [options, setOptions] = React.useState<AsyncComboboxOption[]>([]);
const [count, setCount] = React.useState(0);
const [loading, setLoading] = React.useState(false);
const [loadingMore, setLoadingMore] = React.useState(false);
React.useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(search.trim()), 300);
return () => window.clearTimeout(timer);
}, [search]);
const selected = React.useMemo(
() => options.find((option) => option.value === value),
[options, value],
);
const fetchPage = React.useCallback(
async (offset: number, append = false) => {
if (append) setLoadingMore(true);
else setLoading(true);
try {
const data = await loadOptions({ search: debouncedSearch, limit: pageSize, offset });
setCount(data.count);
setOptions((current) => {
const next = append ? [...current, ...data.results] : data.results;
const byValue = new Map(next.map((option) => [option.value, option]));
return Array.from(byValue.values());
});
} finally {
setLoading(false);
setLoadingMore(false);
}
},
[debouncedSearch, loadOptions, pageSize],
);
React.useEffect(() => {
if (!open) return;
void fetchPage(0);
}, [fetchPage, open]);
const hasMore = options.length < count;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn("w-full justify-between gap-2", className)}
>
<span className="truncate text-right">{selected?.label || value || placeholder}</span>
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start" dir="rtl">
<Command shouldFilter={false}>
<CommandInput value={search} onValueChange={setSearch} placeholder={searchPlaceholder} />
<CommandList>
{loading ? (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
در حال جستجو...
</div>
) : (
<>
<CommandEmpty>{emptyText}</CommandEmpty>
<CommandGroup>
{allowClear ? (
<CommandItem
value="__clear"
onSelect={() => {
onChange(null);
setOpen(false);
}}
>
<Check className={cn("ml-2 h-4 w-4", !value ? "opacity-100" : "opacity-0")} />
همه موارد
</CommandItem>
) : null}
{options.map((option) => (
<CommandItem
key={option.value}
value={option.value}
onSelect={() => {
onChange(option.value === value ? null : option.value);
setOpen(false);
}}
className="flex items-center justify-between gap-2"
>
<span className="min-w-0 flex-1 text-right">
<span className="block truncate">{option.label}</span>
{option.description ? (
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
) : null}
</span>
<Check className={cn("h-4 w-4", value === option.value ? "opacity-100" : "opacity-0")} />
</CommandItem>
))}
</CommandGroup>
{hasMore ? (
<div className="p-2">
<Button
type="button"
variant="ghost"
size="sm"
className="w-full"
disabled={loadingMore}
onClick={() => void fetchPage(options.length, true)}
>
{loadingMore ? <Loader2 className="ml-2 h-4 w-4 animate-spin" /> : null}
بارگذاری بیشتر
</Button>
</div>
) : null}
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useEffect, useState } from "react";
import { Bookmark, Heart, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import { cn, toPersianDigits } from "@/lib/utils";
type BlogPostActionsProps = {
slug: string;
initialLikes: number;
initialSaves: number;
};
export default function BlogPostActions({
slug,
initialLikes,
initialSaves,
}: BlogPostActionsProps) {
const { isAuthenticated } = useAuth();
const [loadingAction, setLoadingAction] = useState<"like" | "save" | null>(null);
const [interaction, setInteraction] = useState<Types.BlogInteractionSchema>({
liked: false,
saved: false,
likes_count: initialLikes,
saves_count: initialSaves,
comments_count: 0,
});
useEffect(() => {
let mounted = true;
if (!isAuthenticated) return;
api.getBlogInteraction(slug)
.then((data) => {
if (mounted) setInteraction(data);
})
.catch(() => undefined);
return () => {
mounted = false;
};
}, [isAuthenticated, slug]);
const toggleLike = async () => {
if (!isAuthenticated || loadingAction) return;
setLoadingAction("like");
try {
setInteraction(await api.toggleLike(slug));
} finally {
setLoadingAction(null);
}
};
const toggleSave = async () => {
if (!isAuthenticated || loadingAction) return;
setLoadingAction("save");
try {
setInteraction(await api.toggleSave(slug));
} finally {
setLoadingAction(null);
}
};
return (
<div className="flex flex-wrap items-center justify-center gap-3 border-t border-border/70 pt-6" dir="rtl">
<Button
type="button"
variant="ghost"
size="lg"
onClick={toggleLike}
disabled={!isAuthenticated || Boolean(loadingAction)}
className="gap-2 rounded-full border border-border/60 bg-background/80 px-5 shadow-sm backdrop-blur hover:bg-rose-50 hover:text-rose-600 dark:hover:bg-rose-950/30"
aria-label="پسندیدن نوشته"
>
{loadingAction === "like" ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Heart
className={cn(
"h-5 w-5",
interaction.liked && "fill-rose-500 text-rose-500",
)}
/>
)}
<span>{toPersianDigits(interaction.likes_count)}</span>
</Button>
<Button
type="button"
variant="ghost"
size="icon"
onClick={toggleSave}
disabled={!isAuthenticated || Boolean(loadingAction)}
className="rounded-full border border-border/60 bg-background/80 shadow-sm backdrop-blur hover:bg-amber-50 hover:text-amber-600 dark:hover:bg-amber-950/30"
aria-label="ذخیره نوشته"
>
{loadingAction === "save" ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Bookmark
className={cn(
"h-5 w-5",
interaction.saved && "fill-amber-500 text-amber-500",
)}
/>
)}
</Button>
{!isAuthenticated ? (
<span className="basis-full text-center text-xs text-muted-foreground">
برای پسندیدن یا ذخیره کردن وارد حساب کاربری شوید.
</span>
) : null}
</div>
);
}

View File

@@ -0,0 +1,507 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Check,
Edit3,
Eye,
EyeOff,
Loader2,
MessageSquare,
Reply,
Send,
Trash2,
X,
} from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type * as Types from "@/lib/types";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { Link } from "@/lib/router";
import { cn, formatJalaliDate, resolveErrorMessage, toPersianDigits } from "@/lib/utils";
import { useToast } from "@/hooks/use-toast";
type Props = {
slug: string;
initialComments: number;
};
type FlattenedReply = {
comment: Types.CommentSchema;
replyTo: Types.CommentSchema;
};
function displayName(author: Types.CommentSchema["author"]) {
return [author.first_name, author.last_name].filter(Boolean).join(" ") || author.username;
}
function avatarInitial(author: Types.CommentSchema["author"]) {
return displayName(author).trim()[0] || "ک";
}
function visibleCommentCount(comments: Types.CommentSchema[]) {
return comments.reduce((total, comment) => {
if (comment.is_deleted || comment.is_hidden) return total;
return total + 1 + visibleCommentCount(comment.replies || []);
}, 0);
}
function allDescendants(comment: Types.CommentSchema): Types.CommentSchema[] {
return (comment.replies || []).flatMap((reply) => [reply, ...allDescendants(reply)]);
}
function flattenReplies(comment: Types.CommentSchema): FlattenedReply[] {
const replies: FlattenedReply[] = [];
const walk = (items: Types.CommentSchema[] | undefined, parent: Types.CommentSchema) => {
(items || []).forEach((reply) => {
replies.push({ comment: reply, replyTo: parent });
walk(reply.replies, reply);
});
};
walk(comment.replies, comment);
return replies;
}
function findComment(comments: Types.CommentSchema[], id: number | null): Types.CommentSchema | undefined {
if (!id) return undefined;
for (const comment of comments) {
if (comment.id === id) return comment;
const reply = findComment(comment.replies || [], id);
if (reply) return reply;
}
return undefined;
}
export default function BlogPostInteractions({
slug,
initialComments,
}: Props) {
const { user, isAuthenticated } = useAuth();
const { toast } = useToast();
const [comments, setComments] = useState<Types.CommentSchema[]>([]);
const [commentCount, setCommentCount] = useState(initialComments);
const [content, setContent] = useState("");
const [replyTo, setReplyTo] = useState<number | null>(null);
const [editingId, setEditingId] = useState<number | null>(null);
const [editContent, setEditContent] = useState("");
const [deleteTarget, setDeleteTarget] = useState<Types.CommentSchema | null>(null);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [savingEdit, setSavingEdit] = useState(false);
const [moderatingId, setModeratingId] = useState<number | null>(null);
const [highlightedCommentId, setHighlightedCommentId] = useState<number | null>(null);
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const canModerate = Boolean(user?.is_staff || user?.is_superuser || user?.can_review_blog_posts);
const replyTarget = useMemo(
() => findComment(comments, replyTo),
[comments, replyTo],
);
const loadComments = async () => {
const data = await api.getComments(slug);
setComments(data);
setCommentCount(visibleCommentCount(data));
};
useEffect(() => {
let mounted = true;
api.getComments(slug)
.then((data) => {
if (!mounted) return;
setComments(data);
setCommentCount(visibleCommentCount(data));
})
.finally(() => {
if (mounted) setLoading(false);
});
return () => {
mounted = false;
};
}, [slug]);
useEffect(() => () => {
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
}
}, []);
const scrollToComment = (commentId: number) => {
const element = document.getElementById(`blog-comment-${commentId}`);
if (!element) return;
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
}
element.scrollIntoView({ behavior: "smooth", block: "center" });
setHighlightedCommentId(commentId);
highlightTimeoutRef.current = setTimeout(() => {
setHighlightedCommentId(null);
highlightTimeoutRef.current = null;
}, 1800);
};
const submitComment = async () => {
const trimmed = content.trim();
if (!trimmed) return;
try {
setSubmitting(true);
await api.createComment(slug, { content: trimmed, parent_id: replyTo ?? undefined });
setContent("");
setReplyTo(null);
await loadComments();
toast({ title: "کامنت ثبت شد", variant: "success" });
} catch (error) {
toast({
title: "ثبت کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSubmitting(false);
}
};
const hideComment = async (commentId: number) => {
try {
setModeratingId(commentId);
await api.hideComment(commentId);
await loadComments();
toast({ title: "کامنت مخفی شد", variant: "success" });
} catch (error) {
toast({
title: "مخفی کردن کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setModeratingId(null);
}
};
const unhideComment = async (commentId: number) => {
try {
setModeratingId(commentId);
await api.unhideComment(commentId);
await loadComments();
toast({ title: "کامنت دوباره نمایش داده شد", variant: "success" });
} catch (error) {
toast({
title: "نمایش دوباره کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setModeratingId(null);
}
};
const deleteComment = async () => {
if (!deleteTarget) return;
try {
setModeratingId(deleteTarget.id);
await api.deleteComment(deleteTarget.id);
setDeleteTarget(null);
await loadComments();
toast({ title: "کامنت حذف شد", variant: "success" });
} catch (error) {
toast({
title: "حذف کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setModeratingId(null);
}
};
const startEdit = (comment: Types.CommentSchema) => {
setEditingId(comment.id);
setEditContent(comment.content);
setReplyTo(null);
};
const cancelEdit = () => {
setEditingId(null);
setEditContent("");
};
const saveEdit = async () => {
const trimmed = editContent.trim();
if (!editingId || !trimmed) return;
try {
setSavingEdit(true);
await api.updateComment(editingId, { content: trimmed });
await loadComments();
cancelEdit();
toast({ title: "کامنت ویرایش شد", variant: "success" });
} catch (error) {
toast({
title: "ویرایش کامنت ناموفق بود",
description: resolveErrorMessage(error, "دوباره تلاش کنید"),
variant: "destructive",
});
} finally {
setSavingEdit(false);
}
};
const renderComment = (
comment: Types.CommentSchema,
options: {
parentHidden?: boolean;
replyToComment?: Types.CommentSchema;
topLevelParent?: Types.CommentSchema;
} = {},
) => {
const isOwnComment = Boolean(user?.id === comment.author.id);
const isEditing = editingId === comment.id;
const { parentHidden = false, replyToComment, topLevelParent } = options;
const hidden = parentHidden || Boolean(comment.is_hidden);
const isReply = Boolean(replyToComment);
const flattenedReplies = isReply ? [] : flattenReplies(comment);
const showReplyContext = Boolean(
replyToComment && topLevelParent && replyToComment.id !== topLevelParent.id,
);
return (
<div
id={`blog-comment-${comment.id}`}
key={comment.id}
className={cn(
"relative scroll-mt-28 rounded-3xl border p-4 shadow-sm transition-colors duration-300 border-border/70 bg-muted/20",
hidden && "border-amber-400/40 bg-amber-50/50 dark:bg-amber-950/20",
highlightedCommentId === comment.id && "border-primary bg-primary/10 shadow-lg shadow-primary/20 ring-2 ring-primary/30",
)}
>
<div className={cn("flex items-start gap-3 text-right", hidden && "opacity-75")}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/12 text-base font-black text-primary shadow-inner">
{avatarInitial(comment.author)}
</div>
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center justify-start gap-2 text-sm">
<span className="font-semibold">{displayName(comment.author)}</span>
<time className="text-xs text-muted-foreground" dateTime={comment.created_at}>
{formatJalaliDate(comment.created_at)}
</time>
{showReplyContext && replyToComment ? (
<button
type="button"
className="rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary transition hover:bg-primary/20 hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onClick={() => scrollToComment(replyToComment.id)}
>
در پاسخ به {displayName(replyToComment.author)}
</button>
) : null}
{hidden ? (
<span className="rounded-full bg-amber-500/10 px-2 py-0.5 text-xs text-amber-600 dark:text-amber-300">
مخفی شده
</span>
) : null}
</div>
<div>
{isEditing ? (
<div className="space-y-3">
<Textarea
value={editContent}
onChange={(event) => setEditContent(event.target.value)}
className="min-h-24 rounded-2xl bg-background text-right"
/>
<div className="flex flex-wrap justify-start gap-2">
<Button type="button" size="sm" variant="ghost" className="gap-1 rounded-full" onClick={cancelEdit}>
<X className="h-3.5 w-3.5" />
لغو
</Button>
<Button type="button" size="sm" className="gap-1 rounded-full" onClick={saveEdit} disabled={savingEdit || !editContent.trim()}>
{savingEdit ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
ذخیره
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-sm leading-7">{comment.content}</p>
)}
</div>
{!isEditing ? (
<div className="mt-2 flex flex-wrap items-center justify-start gap-2 text-xs text-muted-foreground">
{isAuthenticated && !hidden ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-3 text-xs"
onClick={() => setReplyTo(comment.id)}
>
<Reply className="h-3.5 w-3.5" />
پاسخ
</Button>
) : null}
{isOwnComment && !hidden ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-3 text-xs"
onClick={() => startEdit(comment)}
>
<Edit3 className="h-3.5 w-3.5" />
ویرایش
</Button>
) : null}
{canModerate ? (
<>
{hidden ? (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-3 text-xs text-primary"
onClick={() => unhideComment(comment.id)}
disabled={moderatingId === comment.id}
>
{moderatingId === comment.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Eye className="h-3.5 w-3.5" />}
نمایش مجدد
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-3 text-xs"
onClick={() => hideComment(comment.id)}
disabled={moderatingId === comment.id}
>
{moderatingId === comment.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <EyeOff className="h-3.5 w-3.5" />}
مخفی کردن
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 gap-1 rounded-full px-3 text-xs text-destructive hover:text-destructive"
onClick={() => setDeleteTarget(comment)}
>
<Trash2 className="h-3.5 w-3.5" />
حذف
</Button>
</>
) : null}
</div>
) : null}
</div>
</div>
{flattenedReplies.length ? (
<div className="mt-4 space-y-3 border-r border-primary/15 pr-4">
{flattenedReplies.map(({ comment: reply, replyTo }) => (
renderComment(reply, {
parentHidden: hidden,
replyToComment: replyTo,
topLevelParent: comment,
})
))}
</div>
) : null}
</div>
);
};
const deleteReplyCount = deleteTarget ? allDescendants(deleteTarget).length : 0;
return (
<>
<Card className="mt-10 overflow-hidden rounded-[2rem] border border-border/70 bg-card/90 shadow-sm" dir="rtl">
<CardHeader className="border-b border-border/60 bg-muted/20 text-right">
<CardTitle className="flex items-center justify-start gap-2 text-2xl">
<MessageSquare className="h-5 w-5 text-primary" />
کامنتها
</CardTitle>
<CardDescription>
{toPersianDigits(commentCount)} کامنت برای این نوشته ثبت شده است.
</CardDescription>
</CardHeader>
<CardContent className="space-y-6 p-4 md:p-6">
{!isAuthenticated ? (
<div className="rounded-3xl border border-dashed border-border/70 bg-muted/20 p-5 text-center text-sm leading-7 text-muted-foreground">
برای ثبت کامنت باید وارد حساب کاربری شوید.
<Button asChild className="mr-3" size="sm">
<Link to="/auth">ورود / ثبتنام</Link>
</Button>
</div>
) : (
<div className="rounded-3xl border border-border/70 bg-muted/20 p-4">
{replyTarget ? (
<div className="mb-3 flex items-center justify-between rounded-2xl bg-background px-3 py-2 text-sm">
<Button variant="ghost" size="sm" onClick={() => setReplyTo(null)}>
لغو پاسخ
</Button>
<span>پاسخ به {displayName(replyTarget.author)}</span>
</div>
) : null}
<Textarea
value={content}
onChange={(event) => setContent(event.target.value)}
placeholder="کامنت خود را بنویسید..."
className="min-h-32 rounded-2xl bg-background text-right"
/>
<div className="mt-3 flex justify-start">
<Button onClick={submitComment} disabled={submitting || !content.trim()} className="gap-2 rounded-full">
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
ثبت کامنت
</Button>
</div>
</div>
)}
{loading ? (
<div className="flex justify-center py-8 text-muted-foreground">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : comments.length ? (
<div className="space-y-5">{comments.map((comment) => renderComment(comment))}</div>
) : (
<div className="rounded-3xl border border-dashed border-border/70 p-8 text-center text-sm text-muted-foreground">
هنوز کامنتی ثبت نشده است.
</div>
)}
</CardContent>
</Card>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent dir="rtl" className="text-right">
<AlertDialogHeader>
<AlertDialogTitle>حذف کامنت</AlertDialogTitle>
<AlertDialogDescription className="leading-7">
این عملیات کامنت را بهصورت نرم حذف میکند و دیگر در سایت نمایش داده نمیشود.
{deleteReplyCount ? (
<span className="mt-2 block font-medium text-destructive">
{toPersianDigits(deleteReplyCount)} پاسخ وابسته به این کامنت هم حذف میشود.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:justify-start">
<AlertDialogCancel>انصراف</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={deleteComment}
>
حذف کامنت
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { useEffect, useState } from "react";
import type { MarkdownHeading } from "@/lib/markdown-headings";
import { cn } from "@/lib/utils";
type Props = {
headings: MarkdownHeading[];
};
function getParentHeading(headings: MarkdownHeading[], index: number) {
const heading = headings[index];
for (let cursor = index - 1; cursor >= 0; cursor -= 1) {
if (headings[cursor].level < heading.level) {
return headings[cursor];
}
}
return null;
}
export default function BlogTableOfContents({ headings }: Props) {
const [activeId, setActiveId] = useState(headings[0]?.id ?? "");
const parentById = new Map<string, string | null>();
headings.forEach((heading, index) => {
parentById.set(heading.id, getParentHeading(headings, index)?.id ?? null);
});
const activeBranch = new Set<string>();
let cursor = activeId;
while (cursor) {
activeBranch.add(cursor);
cursor = parentById.get(cursor) ?? "";
}
const visibleHeadings = headings.filter((heading) => {
const parentId = parentById.get(heading.id);
if (!parentId) return true;
return activeBranch.has(parentId);
});
useEffect(() => {
if (!headings.length) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
if (visible?.target.id) {
setActiveId(visible.target.id);
}
},
{
rootMargin: "-20% 0px -65% 0px",
threshold: [0, 1],
},
);
headings.forEach((heading) => {
const element = document.getElementById(heading.id);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [headings]);
if (!headings.length) {
return <p className="text-sm leading-7 text-muted-foreground">برای این نوشته فهرست محتوا ثبت نشده است.</p>;
}
const scrollToHeading = (id: string) => {
const element = document.getElementById(id);
if (!element) return;
element.scrollIntoView({ behavior: "smooth", block: "start" });
window.history.replaceState(null, "", `#${id}`);
setActiveId(id);
};
return (
<nav className="space-y-1 text-sm">
{visibleHeadings.map((heading) => {
const active = activeId === heading.id;
return (
<button
key={heading.id}
type="button"
onClick={() => scrollToHeading(heading.id)}
className={cn(
"block w-full rounded-2xl px-3 py-2 text-right leading-6 transition",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-primary/10 hover:text-primary",
)}
style={{ paddingRight: `${(heading.level - 1) * 0.85 + 0.75}rem` }}
>
{heading.text}
</button>
);
})}
</nav>
);
}

View File

@@ -0,0 +1,58 @@
import type * as Types from "@/lib/types";
import { cn } from "@/lib/utils";
type BlogThumbnailProps = {
post: Pick<Types.PostListSchema, "title" | "category" | "absolute_featured_image_thumbnail_url" | "absolute_featured_image_preview_url" | "absolute_featured_image_url" | "featured_image">;
imageUrl?: string | null;
className?: string;
imageClassName?: string;
priority?: boolean;
};
function initials(title: string) {
return title
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part[0])
.join("");
}
export default function BlogThumbnail({
post,
imageUrl,
className,
imageClassName,
priority = false,
}: BlogThumbnailProps) {
if (imageUrl) {
return (
<div className={cn("overflow-hidden bg-muted", className)}>
<img
src={imageUrl}
alt={post.title}
className={cn("h-full w-full object-cover transition duration-500 group-hover:scale-[1.03]", imageClassName)}
loading={priority ? "eager" : "lazy"}
/>
</div>
);
}
return (
<div
className={cn(
"relative overflow-hidden bg-[radial-gradient(circle_at_20%_20%,rgba(34,197,94,0.28),transparent_32%),linear-gradient(135deg,#0f3d2e,#163f59_52%,#111827)] text-white",
className,
)}
>
<div className="absolute inset-0 bg-[linear-gradient(45deg,rgba(255,255,255,0.08)_25%,transparent_25%,transparent_50%,rgba(255,255,255,0.08)_50%,rgba(255,255,255,0.08)_75%,transparent_75%,transparent)] bg-[length:28px_28px] opacity-25" />
<div className="relative flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<span className="rounded-full border border-white/25 bg-white/15 px-3 py-1 text-xs backdrop-blur">
{post.category?.name || "بلاگ"}
</span>
<span className="text-5xl font-black tracking-tight">{initials(post.title) || "گـ"}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,58 @@
"use client";
import type { ReactNode } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
type ConfirmActionProps = {
trigger: ReactNode;
title: string;
description: ReactNode;
confirmLabel?: string;
cancelLabel?: string;
onConfirm: () => unknown | Promise<unknown>;
disabled?: boolean;
};
export default function ConfirmAction({
trigger,
title,
description,
confirmLabel = "حذف",
cancelLabel = "انصراف",
onConfirm,
disabled = false,
}: ConfirmActionProps) {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
{trigger}
</AlertDialogTrigger>
<AlertDialogContent dir="rtl">
<AlertDialogHeader className="text-right">
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription className="leading-7">{description}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="gap-2 sm:justify-start">
<AlertDialogCancel>{cancelLabel}</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
disabled={disabled}
onClick={() => void onConfirm()}
>
{confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -1,75 +1,55 @@
import * as React from "react";
import { Link } from "react-router-dom";
"use client";
import { Link } from "@/lib/router";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
import { Instagram, Send, Twitter, Linkedin } from "lucide-react";
import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح داده‌ام
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
export default function Footer() {
// const { toast } = useToast();
// const [email, setEmail] = React.useState("");
// const [loading, setLoading] = React.useState(false);
const year = new Date().getFullYear();
// const validateEmail = (v: string) =>
// /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
// const onSubmit = async (e: React.FormEvent) => {
// e.preventDefault();
// const em = email.trim();
// if (!validateEmail(em)) {
// toast({ title: "ایمیل نامعتبر است", description: "لطفاً یک ایمیل صحیح وارد کنید.", variant: "destructive" });
// return;
// }
// try {
// setLoading(true);
// const response = await api.subscribeNewsletter(em);
// if (response.success) {
// toast({ title: "عضویت موفق", description: response.message });
// setEmail("");
// } else {
// toast({ title: "عضویت ناموفق", description: response.message, variant: "destructive" });
// }
// } catch (err: any) {
// toast({ title: "خطا", description: err?.message || "مشکلی رخ داد.", variant: "destructive" });
// } finally {
// setLoading(false);
// }
// };
return (
<footer className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40" dir="rtl">
<footer
className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40"
dir="rtl"
>
<div className="container mx-auto px-4 py-10">
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
{/* برند + درباره + اینماد */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
</div>
<p className="text-sm text-muted-foreground leading-7">
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهٔ دانشجویی و پیوند با صنعت.
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
</p>
</div>
{/* لینک‌های سریع */}
<div>
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
<ul className="space-y-2 text-sm">
<li><Link to="/" className="text-muted-foreground hover:text-foreground">خانه</Link></li>
<li><Link to="/events" className="text-muted-foreground hover:text-foreground">رویدادها</Link></li>
<li><Link to="/blog" className="text-muted-foreground hover:text-foreground">بلاگ</Link></li>
<li><Link to="/about" className="text-muted-foreground hover:text-foreground">دربارهٔ انجمن</Link></li>
{/* <li><Link to="/contact" className="text-muted-foreground hover:text-foreground">تماس با ما</Link></li> */}
{/* <li><Link to="/rules" className="text-muted-foreground hover:text-foreground">قوانین و حریم خصوصی</Link></li> */}
<li>
<Link to="/" className="text-muted-foreground hover:text-foreground">
خانه
</Link>
</li>
<li>
<Link to="/events" className="text-muted-foreground hover:text-foreground">
رویدادها
</Link>
</li>
<li>
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
بلاگ
</Link>
</li>
<li>
<Link to="/about" className="text-muted-foreground hover:text-foreground">
دربارهی انجمن
</Link>
</li>
</ul>
</div>
{/* اطلاعات تماس / شبکه‌های اجتماعی */}
<div>
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
<ul className="space-y-2 text-sm text-muted-foreground">
@@ -78,53 +58,49 @@ export default function Footer() {
</ul>
<div className="mt-4 flex items-center gap-2">
<a href="https://Instagram.com/guilance.ir" target="_blank" rel="noreferrer" className="inline-flex">
<a
href="https://Instagram.com/guilance.ir"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
<Instagram className="h-4 w-4" />
</Button>
</a>
<a href="https://t.me/guilance" target="_blank" rel="noreferrer" className="inline-flex">
<a
href="https://t.me/guilance"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
<Send className="h-4 w-4" />
</Button>
</a>
<a href="https://www.linkedin.com/in/amiirkhl/" target="_blank" rel="noreferrer" className="inline-flex">
<a
href="https://www.linkedin.com/in/amiirkhl/"
target="_blank"
rel="noreferrer"
className="inline-flex"
>
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
<Linkedin className="h-4 w-4" />
</Button>
</a>
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="ایکس (توییتر)">
<Button
variant="outline"
size="icon"
className="h-9 w-9"
aria-label="ایکس (توییتر)"
>
<Twitter className="h-4 w-4" />
</Button>
</a>
</div>
</div>
{/* خبرنامه */}
{/* <div>
<h4 className="mb-3 text-base font-semibold">عضویت در خبرنامه</h4>
<p className="mb-3 text-sm text-muted-foreground">
برای اطلاع از رویدادها و اخبار انجمن، ایمیل خود را وارد کنید.
</p>
<form onSubmit={onSubmit} className="flex flex-col sm:flex-row gap-2">
<Input
type="email"
inputMode="email"
placeholder="ایمیل شما"
dir="ltr"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="sm:flex-1 text-left"
/>
<Button type="submit" disabled={loading}>
{loading ? "در حال عضویت..." : "عضویت"}
</Button>
</form>
<p className="mt-2 text-xs text-muted-foreground">
با عضویت، با <Link to="/rules" className="underline underline-offset-4">قوانین و حریم خصوصی</Link> موافقم.
</p>
</div> */}
<div className="justify-self-end">
<a
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
@@ -134,7 +110,7 @@ export default function Footer() {
>
<img
src="/enamad.png"
width="125px"
width="125"
alt="نماد اعتماد الکترونیکی"
referrerPolicy="origin"
style={{ cursor: "pointer" }}
@@ -144,12 +120,10 @@ export default function Footer() {
</div>
</div>
{/* خط جداکننده */}
<div className="my-8 h-px w-full bg-border" />
{/* کپی‌رایت */}
<div className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
<div>© {year} انجمن علمی کامپیوتر گیلان تمامی حقوق محفوظ است.</div>
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
</div>
</div>
</footer>

View File

@@ -0,0 +1,175 @@
"use client";
import * as React from "react";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import ProgressiveImage from "@/components/ProgressiveImage";
import { Button } from "@/components/ui/button";
export type GalleryLightboxItem = {
id: number | string;
alt: string;
title?: string | null;
previewSrc?: string | null;
blurSrc?: string | null;
fullSrc?: string | null;
};
type GalleryLightboxProps = {
items: GalleryLightboxItem[];
open: boolean;
initialIndex: number;
onOpenChange: (open: boolean) => void;
};
export default function GalleryLightbox({
items,
open,
initialIndex,
onOpenChange,
}: GalleryLightboxProps) {
const [currentIndex, setCurrentIndex] = React.useState(initialIndex);
React.useEffect(() => {
if (open) {
setCurrentIndex(initialIndex);
}
}, [initialIndex, open]);
const currentItem = items[currentIndex];
const goToPrevious = React.useCallback(() => {
if (!items.length) {
return;
}
setCurrentIndex((current) => (current - 1 + items.length) % items.length);
}, [items.length]);
const goToNext = React.useCallback(() => {
if (!items.length) {
return;
}
setCurrentIndex((current) => (current + 1) % items.length);
}, [items.length]);
React.useEffect(() => {
if (!open) {
return;
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
goToPrevious();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
goToNext();
return;
}
if (event.key === "Escape") {
onOpenChange(false);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [goToNext, goToPrevious, onOpenChange, open]);
React.useEffect(() => {
if (!open || !items.length) {
return;
}
const neighborIndexes = [
(currentIndex - 1 + items.length) % items.length,
(currentIndex + 1) % items.length,
];
neighborIndexes.forEach((index) => {
const src = items[index]?.fullSrc || items[index]?.previewSrc;
if (!src) {
return;
}
const image = new window.Image();
image.src = src;
});
}, [currentIndex, items, open]);
if (!currentItem) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="w-[min(96vw,1100px)] max-w-none overflow-hidden border-none bg-transparent p-0 shadow-none"
dir="rtl"
>
<DialogTitle className="sr-only">{currentItem.title || currentItem.alt}</DialogTitle>
<DialogDescription className="sr-only">
پیشنمایش تصویر {currentIndex + 1} از {items.length}
</DialogDescription>
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black/90 text-white shadow-2xl">
<div className="relative min-h-[70vh]">
<button
type="button"
aria-label="تصویر قبلی"
className="absolute inset-y-0 left-0 z-10 w-1/4 cursor-w-resize bg-transparent"
onClick={goToPrevious}
/>
<button
type="button"
aria-label="تصویر بعدی"
className="absolute inset-y-0 right-0 z-10 w-1/4 cursor-e-resize bg-transparent"
onClick={goToNext}
/>
<div className="absolute left-4 top-4 z-20 flex gap-2">
<Button
type="button"
size="icon"
variant="secondary"
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
onClick={goToNext}
>
<ChevronRight className="h-5 w-5" />
</Button>
<Button
type="button"
size="icon"
variant="secondary"
className="border-white/10 bg-black/45 text-white hover:bg-black/70"
onClick={goToPrevious}
>
<ChevronLeft className="h-5 w-5" />
</Button>
</div>
<ProgressiveImage
src={currentItem.fullSrc || currentItem.previewSrc}
blurSrc={currentItem.blurSrc || currentItem.previewSrc}
alt={currentItem.alt}
loading="eager"
wrapperClassName="min-h-[70vh]"
className="max-h-[80vh] w-full object-contain"
/>
</div>
<div className="flex items-center justify-between gap-4 border-t border-white/10 bg-black/75 px-5 py-4 text-sm">
<div className="min-w-0">
<p className="truncate font-medium">{currentItem.title || currentItem.alt}</p>
</div>
<p className="shrink-0 text-white/70">
{currentIndex + 1} / {items.length}
</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,15 +0,0 @@
import { Outlet } from 'react-router-dom';
import Navbar from './Navbar';
import Footer from './Footer';
import ScrollToTop from './ScrollToTop';
export default function Layout() {
return (
<div className="min-h-screen">
<ScrollToTop onlyOnPush={false} smooth={false} />
<Navbar />
<Outlet />
<Footer />
</div>
);
}

View File

@@ -1,101 +1,164 @@
import React from 'react';
import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
"use client";
type MarkdownSize = 'sm' | 'base' | 'lg';
import React, { useState } from "react";
import { Check, Copy } from "lucide-react";
import ReactMarkdown from "react-markdown";
import type { PluggableList } from "unified";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize from "rehype-sanitize";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
import { cn } from "@/lib/utils";
type MarkdownSize = "sm" | "base" | "lg";
type MarkdownProps = {
content?: string;
allowHtml?: boolean;
className?: string;
dir?: 'rtl' | 'ltr';
dir?: "rtl" | "ltr";
justify?: boolean;
size?: MarkdownSize;
};
function CodeBlock({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) {
const [copied, setCopied] = useState(false);
const language = /language-([\w-]+)/.exec(className || "")?.[1] || "text";
const code = String(children).replace(/\n$/, "");
const copyCode = async () => {
if (!navigator.clipboard) return;
await navigator.clipboard.writeText(code);
setCopied(true);
window.setTimeout(() => setCopied(false), 1600);
};
return (
<div
dir="ltr"
className="my-5 overflow-hidden rounded-2xl border border-slate-700/70 bg-[#0f172a] text-left shadow-xl shadow-slate-950/10"
>
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs text-slate-300">
<span className="font-mono uppercase tracking-wide">{language}</span>
<button
type="button"
onClick={copyCode}
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{copied ? "Copied" : "Copy"}
</button>
</div>
<SyntaxHighlighter
language={language}
style={oneDark}
PreTag="div"
customStyle={{
margin: 0,
background: "transparent",
direction: "ltr",
padding: "1rem",
}}
codeTagProps={{
dir: "ltr",
style: {
direction: "ltr",
textAlign: "left",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
},
}}
>
{code}
</SyntaxHighlighter>
</div>
);
}
export default function Markdown({
content = '',
content = "",
allowHtml = false,
className = '',
dir = 'rtl',
className = "",
dir = "rtl",
justify = false,
size = 'sm',
size = "sm",
}: MarkdownProps) {
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
const headings = extractMarkdownHeadings(content);
let headingIndex = 0;
const baseSizeClass =
size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base';
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
const hScale =
size === 'sm'
? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' }
: size === 'base'
? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' }
: { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' };
size === "sm"
? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" }
: size === "base"
? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" }
: { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" };
const justifyStyle: React.CSSProperties | undefined = justify
? { textAlign: 'justify', textJustify: 'inter-word' }
? { textAlign: "justify", textJustify: "inter-word" }
: undefined;
const nextHeadingId = (level: 1 | 2 | 3) => {
while (headingIndex < headings.length) {
const heading = headings[headingIndex];
headingIndex += 1;
if (heading.level === level) return heading.id;
}
return undefined;
};
return (
<div
dir={dir}
className={`markdown-body ${baseSizeClass} text-right leading-7 break-words ${className}`}
className={cn("markdown-body break-words text-right leading-8", baseSizeClass, className)}
style={justifyStyle}
>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={rehypePlugins}
components={{
h1: (p) => <h1 className={`mt-6 font-bold ${hScale.h1}`} {...p} />,
h2: (p) => <h2 className={`mt-6 font-bold ${hScale.h2}`} {...p} />,
h3: (p) => <h3 className={`mt-5 font-semibold ${hScale.h3}`} {...p} />,
h4: (p) => <h4 className={`mt-4 font-semibold ${hScale.h4}`} {...p} />,
p: (p) => <p className="my-3" {...p} />,
a: (p) => <a className="underline decoration-primary hover:opacity-90 break-all" target="_blank" rel="noopener noreferrer" {...p} />,
ul: (p) => <ul className="my-3 list-disc ps-6 space-y-1.5" {...p} />,
ol: (p) => <ol className="my-3 list-decimal ps-6 space-y-1.5" {...p} />,
li: (p) => <li className="[&>ul]:my-1.5 [&>ol]:my-1.5" {...p} />,
hr: (p) => <hr className="my-5 border-muted" {...p} />,
h1: (p) => <h1 {...p} id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
h2: (p) => <h2 {...p} id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
h3: (p) => <h3 {...p} id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
p: (p) => <p className="my-4" {...p} />,
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,
ul: (p) => <ul className="my-4 list-disc space-y-1.5 pe-0 ps-6" {...p} />,
ol: (p) => <ol className="my-4 list-decimal space-y-1.5 pe-0 ps-6" {...p} />,
li: (p) => <li className="[&>ol]:my-1.5 [&>ul]:my-1.5" {...p} />,
hr: (p) => <hr className="my-6 border-muted" {...p} />,
blockquote: (p) => (
<blockquote className="my-3 border-r-4 pr-4 italic text-muted-foreground" {...p} />
<blockquote className="my-4 rounded-2xl border-r-4 border-primary bg-muted/40 py-3 pr-4 italic text-muted-foreground" {...p} />
),
code: ({ className, children, node, ...p }) => {
const isInline =
node?.tagName === 'code' &&
!/language-/.test(className || '') &&
!String(children).includes('\n');
node?.tagName === "code" &&
!/language-/.test(className || "") &&
!String(children).includes("\n");
if (isInline) {
return (
<code className="rounded bg-muted px-1 py-0.5 text-[0.9em]" {...p}>
<code dir="ltr" className="rounded bg-muted px-1.5 py-0.5 text-[0.9em]" {...p}>
{children}
</code>
);
}
return (
<code className={className} {...p}>
{children}
</code>
);
return <CodeBlock className={className}>{children}</CodeBlock>;
},
pre: ({ className = '', children, ...p }) => (
<pre
className={[
"my-4 overflow-x-auto rounded-md bg-muted p-4 text-[0.9em]",
className,
].filter(Boolean).join(" ")}
{...p}
>
{children}
</pre>
),
pre: ({ children }) => <>{children}</>,
table: (p) => (
<div className="my-3 overflow-x-auto">
<div className="my-4 overflow-x-auto">
<table className="w-full border-collapse" {...p} />
</div>
),

View File

@@ -0,0 +1,683 @@
"use client";
import { useEffect, useRef, useState, type ComponentType } from "react";
import { defaultKeymap, history, historyKeymap, redo, undo } from "@codemirror/commands";
import { markdown } from "@codemirror/lang-markdown";
import { bracketMatching, defaultHighlightStyle, HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { search, searchKeymap } from "@codemirror/search";
import { Compartment, EditorState, RangeSetBuilder, type Extension } from "@codemirror/state";
import { tags } from "@lezer/highlight";
import {
Decoration,
drawSelection,
dropCursor,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
rectangularSelection,
} from "@codemirror/view";
import {
Bold,
Code2,
Heading1,
Heading2,
Heading3,
HelpCircle,
Image as ImageIcon,
IndentDecrease,
IndentIncrease,
Italic,
Link as LinkIcon,
List,
ListChecks,
ListOrdered,
Minus,
Quote,
Redo2,
Strikethrough,
Table2,
TextCursorInput,
Undo2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export type MarkdownDirectionMode = "auto" | "rtl" | "ltr";
type MarkdownEditorProps = {
value: string;
onChange: (value: string) => void;
minHeight?: string;
directionMode?: MarkdownDirectionMode;
onDirectionModeChange?: (mode: MarkdownDirectionMode) => void;
onSave?: () => unknown | Promise<unknown>;
className?: string;
};
type ToolbarAction = {
key: string;
label: string;
icon: ComponentType<{ className?: string }>;
run: () => void;
};
type InsertDialogState =
| { type: "link"; from: number; to: number; selectedText: string }
| { type: "image"; from: number; to: number; selectedText: string }
| null;
const rtlStrongPattern = /[\u0590-\u08FF\uFB1D-\uFDFD\uFE70-\uFEFC]/;
const ltrStrongPattern = /[A-Za-z0-9]/;
const codeFontFamily =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
const codeFontHighlightStyle = HighlightStyle.define([
{
tag: [tags.monospace, tags.processingInstruction],
fontFamily: codeFontFamily,
},
]);
function detectLineDirection(text: string): "rtl" | "ltr" {
for (const char of text.trimStart()) {
if (rtlStrongPattern.test(char)) return "rtl";
if (ltrStrongPattern.test(char)) return "ltr";
}
return "ltr";
}
function directionExtension(mode: MarkdownDirectionMode): Extension {
if (mode !== "auto") {
return [
EditorView.editorAttributes.of({ dir: mode }),
EditorView.contentAttributes.of({ dir: mode }),
EditorView.perLineTextDirection.of(false),
];
}
return [
EditorView.editorAttributes.of({ dir: "rtl" }),
EditorView.perLineTextDirection.of(true),
EditorView.decorations.compute(["doc"], (state) => {
const builder = new RangeSetBuilder<Decoration>();
for (let lineNumber = 1; lineNumber <= state.doc.lines; lineNumber += 1) {
const line = state.doc.line(lineNumber);
builder.add(
line.from,
line.from,
Decoration.line({ attributes: { dir: detectLineDirection(line.text) } }),
);
}
return builder.finish();
}),
];
}
function editorTheme(minHeight: string): Extension {
return EditorView.theme({
"&": {
minHeight,
backgroundColor: "hsl(var(--muted) / 0.28)",
color: "hsl(var(--foreground))",
fontSize: "14px",
direction: "rtl",
},
".cm-scroller": {
minHeight,
fontFamily: "inherit",
lineHeight: "1.9",
},
".cm-content": {
caretColor: "hsl(var(--primary))",
padding: "1rem",
backgroundColor: "hsl(var(--muted) / 0.16)",
},
".cm-line": {
padding: "0 0.25rem",
unicodeBidi: "plaintext",
},
".cm-monospace": {
fontFamily: codeFontFamily,
},
".cm-line[dir='rtl']": {
textAlign: "right",
},
".cm-line[dir='ltr']": {
textAlign: "left",
},
".cm-activeLine": {
backgroundColor: "hsl(var(--background) / 0.82)",
},
".cm-selectionBackground, &.cm-focused .cm-selectionBackground": {
backgroundColor: "hsl(var(--primary) / 0.2)",
},
"&.cm-focused": {
outline: "none",
},
".cm-cursor": {
borderLeftColor: "hsl(var(--primary))",
},
".cm-tooltip": {
borderRadius: "0.75rem",
borderColor: "hsl(var(--border))",
backgroundColor: "hsl(var(--popover))",
color: "hsl(var(--popover-foreground))",
},
".cm-gutters": {
display: "none",
},
});
}
function insertAtSelection(view: EditorView, text: string, cursorOffset = text.length) {
const selection = view.state.selection.main;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert: text },
selection: { anchor: selection.from + cursorOffset },
scrollIntoView: true,
});
view.focus();
}
function wrapSelection(view: EditorView, before: string, after = before, placeholder = "متن") {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function selectedLines(view: EditorView) {
const selection = view.state.selection.main;
const startLine = view.state.doc.lineAt(selection.from);
const endLine = view.state.doc.lineAt(selection.to);
return { startLine, endLine };
}
function prefixLines(view: EditorView, makePrefix: (index: number) => string) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
changes.push({ from: line.from, insert: makePrefix(lineNumber - startLine.number + 1) });
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function indentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
if (/^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, insert: " " });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function outdentListLines(view: EditorView) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to: number; insert: string }> = [];
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\t| {1,2})/);
if (match && /^\s*(?:[-*+]|\d+\.)(?:\s+\[[ xX]\])?\s+/.test(line.text)) {
changes.push({ from: line.from, to: line.from + match[1].length, insert: "" });
}
}
if (!changes.length) return false;
view.dispatch({ changes, scrollIntoView: true });
view.focus();
return true;
}
function setHeading(view: EditorView, level: 1 | 2 | 3) {
const { startLine, endLine } = selectedLines(view);
const changes: Array<{ from: number; to?: number; insert: string }> = [];
const marker = `${"#".repeat(level)} `;
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
const line = view.state.doc.line(lineNumber);
const match = line.text.match(/^(\s{0,3})(#{1,6}\s+)/);
if (match) {
const prefixStart = line.from + match[1].length;
changes.push({ from: prefixStart, to: prefixStart + match[2].length, insert: marker });
} else {
changes.push({ from: line.from, insert: marker });
}
}
view.dispatch({ changes, scrollIntoView: true });
view.focus();
}
function wrapBlock(view: EditorView, before: string, after: string, placeholder: string) {
const selection = view.state.selection.main;
const selected = view.state.doc.sliceString(selection.from, selection.to) || placeholder;
const insert = `${before}${selected}${after}`;
view.dispatch({
changes: { from: selection.from, to: selection.to, insert },
selection: {
anchor: selection.from + before.length,
head: selection.from + before.length + selected.length,
},
scrollIntoView: true,
});
view.focus();
}
function getSelectedText(view: EditorView) {
const selection = view.state.selection.main;
return view.state.doc.sliceString(selection.from, selection.to);
}
function insertLinkAtSelection(view: EditorView, from: number, to: number, selectedText: string, text: string, url: string) {
const label = text.trim() || selectedText || "متن لینک";
view.dispatch({
changes: { from, to, insert: `[${label}](${url})` },
selection: { anchor: from + 1, head: from + 1 + label.length },
scrollIntoView: true,
});
view.focus();
}
function insertImageAtSelection(view: EditorView, from: number, to: number, selectedText: string, altText: string, url: string) {
const alt = altText.trim() || selectedText || "تصویر";
view.dispatch({
changes: { from, to, insert: `![${alt}](${url})` },
selection: { anchor: from + 2, head: from + 2 + alt.length },
scrollIntoView: true,
});
view.focus();
}
function runEditorCommand(view: EditorView | null, command: (view: EditorView) => void) {
if (!view) return;
command(view);
}
export default function MarkdownEditor({
value,
onChange,
minHeight = "520px",
directionMode = "auto",
onDirectionModeChange,
onSave,
className,
}: MarkdownEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const initialValueRef = useRef(value);
const initialDirectionModeRef = useRef(directionMode);
const directionCompartment = useRef(new Compartment());
const [guideOpen, setGuideOpen] = useState(false);
const [insertDialog, setInsertDialog] = useState<InsertDialogState>(null);
const [insertUrl, setInsertUrl] = useState("");
const [insertText, setInsertText] = useState("");
onChangeRef.current = onChange;
onSaveRef.current = onSave;
const openInsertDialog = (view: EditorView, type: "link" | "image") => {
const selection = view.state.selection.main;
const selectedText = getSelectedText(view);
setInsertDialog({ type, from: selection.from, to: selection.to, selectedText });
setInsertText(selectedText);
setInsertUrl("");
};
const closeInsertDialog = () => {
setInsertDialog(null);
setInsertText("");
setInsertUrl("");
viewRef.current?.focus();
};
const submitInsertDialog = () => {
const view = viewRef.current;
const dialog = insertDialog;
const url = insertUrl.trim();
if (!view || !dialog || !url) return;
if (dialog.type === "link") {
insertLinkAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
} else {
insertImageAtSelection(view, dialog.from, dialog.to, dialog.selectedText, insertText, url);
}
closeInsertDialog();
};
useEffect(() => {
if (!hostRef.current) return undefined;
const extensions: Extension[] = [
highlightSpecialChars(),
history(),
drawSelection(),
dropCursor(),
rectangularSelection(),
bracketMatching(),
highlightActiveLine(),
markdown(),
search({ top: true }),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
syntaxHighlighting(codeFontHighlightStyle),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) {
onChangeRef.current(update.state.doc.toString());
}
}),
keymap.of([
{ key: "Mod-s", run: () => (void onSaveRef.current?.(), true) },
{ key: "Mod-b", run: (view) => (wrapSelection(view, "**"), true) },
{ key: "Mod-i", run: (view) => (wrapSelection(view, "_"), true) },
{ key: "Mod-k", run: (view) => (openInsertDialog(view, "link"), true) },
{ key: "Mod-/", run: () => (setGuideOpen(true), true) },
{ key: "Mod-Shift-7", run: (view) => (prefixLines(view, (index) => `${index}. `), true) },
{ key: "Mod-Shift-8", run: (view) => (prefixLines(view, () => "- "), true) },
{ key: "Mod-e", run: (view) => (wrapSelection(view, "`"), true) },
{ key: "Mod-Alt-c", run: (view) => (wrapBlock(view, "\n```text\n", "\n```\n", "code"), true) },
{ key: "Mod-Alt-i", run: (view) => (openInsertDialog(view, "image"), true) },
{ key: "Mod-Alt-1", run: (view) => (setHeading(view, 1), true) },
{ key: "Mod-Alt-2", run: (view) => (setHeading(view, 2), true) },
{ key: "Mod-Alt-3", run: (view) => (setHeading(view, 3), true) },
{
key: "Mod-Alt-t",
run: (view) => (
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
true
),
},
{ key: "Mod-Alt-x", run: (view) => (prefixLines(view, () => "- [ ] "), true) },
{ key: "Mod-Shift-x", run: (view) => (wrapSelection(view, "~~"), true) },
{ key: "Tab", run: indentListLines },
{ key: "Shift-Tab", run: outdentListLines },
...searchKeymap,
...defaultKeymap,
...historyKeymap,
]),
editorTheme(minHeight),
directionCompartment.current.of(directionExtension(initialDirectionModeRef.current)),
];
const state = EditorState.create({
doc: initialValueRef.current,
extensions,
});
const view = new EditorView({ state, parent: hostRef.current });
viewRef.current = view;
return () => {
view.destroy();
viewRef.current = null;
};
}, [minHeight]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const current = view.state.doc.toString();
if (current === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
});
}, [value]);
useEffect(() => {
const view = viewRef.current;
if (!view) return;
view.dispatch({
effects: directionCompartment.current.reconfigure(directionExtension(directionMode)),
});
}, [directionMode]);
const setDirectionMode = (mode: MarkdownDirectionMode) => {
onDirectionModeChange?.(mode);
};
const actions: ToolbarAction[] = [
{ key: "bold", label: "درشت (Ctrl+B)", icon: Bold, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "**")) },
{ key: "italic", label: "کج (Ctrl+I)", icon: Italic, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "_")) },
{
key: "strike",
label: "خط‌خورده (Ctrl+Shift+X)",
icon: Strikethrough,
run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "~~")),
},
{ key: "h1", label: "تیتر ۱ (Ctrl+Alt+1)", icon: Heading1, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 1)) },
{ key: "h2", label: "تیتر ۲ (Ctrl+Alt+2)", icon: Heading2, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 2)) },
{ key: "h3", label: "تیتر ۳ (Ctrl+Alt+3)", icon: Heading3, run: () => runEditorCommand(viewRef.current, (view) => setHeading(view, 3)) },
{ key: "quote", label: "نقل قول", icon: Quote, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "> ")) },
{ key: "ul", label: "فهرست نقطه‌ای (Ctrl+Shift+8)", icon: List, run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- ")) },
{
key: "ol",
label: "فهرست شماره‌ای (Ctrl+Shift+7)",
icon: ListOrdered,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, (index) => `${index}. `)),
},
{
key: "task-list",
label: "فهرست کارها (Ctrl+Alt+X)",
icon: ListChecks,
run: () => runEditorCommand(viewRef.current, (view) => prefixLines(view, () => "- [ ] ")),
},
{ key: "link", label: "لینک (Ctrl+K)", icon: LinkIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "link")) },
{ key: "image", label: "تصویر (Ctrl+Alt+I)", icon: ImageIcon, run: () => runEditorCommand(viewRef.current, (view) => openInsertDialog(view, "image")) },
{ key: "inline-code", label: "کد کوتاه (Ctrl+E)", icon: TextCursorInput, run: () => runEditorCommand(viewRef.current, (view) => wrapSelection(view, "`", "`", "code")) },
{
key: "code",
label: "بلوک کد (Ctrl+Alt+C)",
icon: Code2,
run: () => runEditorCommand(viewRef.current, (view) => wrapBlock(view, "\n```text\n", "\n```\n", "code")),
},
{
key: "table",
label: "جدول (Ctrl+Alt+T)",
icon: Table2,
run: () =>
runEditorCommand(viewRef.current, (view) =>
insertAtSelection(view, "\n| ستون اول | ستون دوم |\n| --- | --- |\n| مقدار | مقدار |\n"),
),
},
{ key: "hr", label: "خط جداکننده", icon: Minus, run: () => runEditorCommand(viewRef.current, (view) => insertAtSelection(view, "\n---\n")) },
{ key: "undo", label: "بازگردانی (Ctrl+Z)", icon: Undo2, run: () => runEditorCommand(viewRef.current, (view) => undo(view)) },
{ key: "redo", label: "انجام دوباره (Ctrl+Shift+Z)", icon: Redo2, run: () => runEditorCommand(viewRef.current, (view) => redo(view)) },
];
const shortcutSections = [
{
title: "قالب‌بندی متن",
items: [
["Ctrl/Cmd + B", "درشت کردن متن"],
["Ctrl/Cmd + I", "کج کردن متن"],
["Ctrl/Cmd + Shift + X", "خط‌خورده"],
["Ctrl/Cmd + E", "کد کوتاه درون‌خطی"],
["Ctrl/Cmd + Alt + 1/2/3", "تیترهای سطح ۱، ۲ و ۳"],
],
},
{
title: "بلوک‌ها و ساختار",
items: [
["Ctrl/Cmd + Shift + 8", "فهرست نقطه‌ای"],
["Ctrl/Cmd + Shift + 7", "فهرست شماره‌ای"],
["Ctrl/Cmd + Alt + X", "فهرست کارها"],
["Ctrl/Cmd + Alt + C", "بلوک کد"],
["Ctrl/Cmd + Alt + T", "جدول"],
["Tab / Shift + Tab", "تورفتگی یا خروج از تورفتگی برای آیتم‌های فهرست"],
],
},
{
title: "درج محتوا",
items: [
["Ctrl/Cmd + K", "لینک"],
["Ctrl/Cmd + Alt + I", "تصویر"],
],
},
{
title: "عملیات و راهنما",
items: [
["Ctrl/Cmd + S", "ذخیره پیش‌نویس"],
["Ctrl/Cmd + F", "جست‌وجو در متن ویرایشگر"],
["Ctrl/Cmd + Z", "بازگردانی"],
["Ctrl/Cmd + Shift + Z", "انجام دوباره"],
["Ctrl/Cmd + /", "باز کردن همین راهنما"],
],
},
];
return (
<div className={cn("overflow-hidden rounded-2xl border bg-muted/30 shadow-inner", className)} dir="rtl">
<TooltipProvider delayDuration={150}>
<div className="flex flex-wrap items-center justify-between gap-2 border-b bg-background/80 p-2">
<div className="flex flex-wrap items-center justify-start gap-1">
{actions.map((action) => {
const Icon = action.icon;
return (
<Tooltip key={action.key}>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-8 w-8" onClick={action.run}>
<Icon className="h-4 w-4" />
<span className="sr-only">{action.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{action.label}</TooltipContent>
</Tooltip>
);
})}
</div>
<div className="flex items-center gap-1 rounded-full border bg-background/80 p-1">
{(["auto", "rtl", "ltr"] as const).map((mode) => (
<Button
key={mode}
type="button"
variant={directionMode === mode ? "default" : "ghost"}
size="sm"
className="h-7 rounded-full px-3 text-xs"
onClick={() => setDirectionMode(mode)}
>
{mode === "auto" ? "Auto" : mode.toUpperCase()}
</Button>
))}
<Tooltip>
<TooltipTrigger asChild>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 rounded-full" onClick={() => setGuideOpen(true)}>
<HelpCircle className="h-3.5 w-3.5" />
<span className="sr-only">راهنمای میانبرها</span>
</Button>
</TooltipTrigger>
<TooltipContent>راهنمای میانبرها</TooltipContent>
</Tooltip>
</div>
</div>
</TooltipProvider>
<div ref={hostRef} className="text-left" />
<Dialog open={guideOpen} onOpenChange={setGuideOpen}>
<DialogContent className="max-h-[88vh] max-w-3xl overflow-y-auto rounded-3xl" dir="rtl">
<DialogHeader className="text-right md:text-right mt-6 mb-2">
<DialogTitle>راهنمای میانبرهای ویرایشگر مارکداون</DialogTitle>
<DialogDescription>
در ویندوز و لینوکس از Ctrl و در مک از Cmd استفاده کنید. میانبرها فقط وقتی ویرایشگر فعال است اجرا میشوند.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 md:grid-cols-2">
{shortcutSections.map((section) => (
<section key={section.title} className="rounded-2xl border bg-muted/20 p-4">
<h3 className="mb-3 text-right font-bold">{section.title}</h3>
<div className="space-y-2">
{section.items.map(([shortcut, description]) => (
<div key={`${section.title}-${shortcut}`} className="flex items-center justify-between gap-3 rounded-xl bg-background/80 px-3 py-2 text-sm">
<span className="text-right text-muted-foreground">{description}</span>
<kbd className="shrink-0 rounded-lg border bg-muted px-2 py-1 font-mono text-[11px] leading-none text-foreground">
{shortcut}
</kbd>
</div>
))}
</div>
</section>
))}
</div>
</DialogContent>
</Dialog>
<Dialog open={Boolean(insertDialog)} onOpenChange={(open) => (open ? undefined : closeInsertDialog())}>
<DialogContent className="max-w-lg rounded-3xl" dir="rtl">
<DialogHeader className="mt-6 text-right md:text-right">
<DialogTitle>{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}</DialogTitle>
<DialogDescription>
{insertDialog?.type === "image"
? "آدرس تصویر و متن جایگزین را وارد کنید. برای فایل‌های آپلودشده می‌توانید لینک را از مرکز آپلود کپی کنید."
: "آدرس مقصد و متن لینک را وارد کنید. اگر متن انتخاب کرده باشید، به عنوان متن لینک استفاده می‌شود."}
</DialogDescription>
</DialogHeader>
<form
className="space-y-4"
onSubmit={(event) => {
event.preventDefault();
submitInsertDialog();
}}
>
<div className="space-y-2">
<Label htmlFor="markdown-insert-url" className="block text-right">
آدرس
</Label>
<Input
id="markdown-insert-url"
value={insertUrl}
onChange={(event) => setInsertUrl(event.target.value)}
placeholder={insertDialog?.type === "image" ? "https://example.com/image.png" : "https://example.com"}
dir="ltr"
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="markdown-insert-text" className="block text-right">
{insertDialog?.type === "image" ? "متن جایگزین" : "متن لینک"}
</Label>
<Input
id="markdown-insert-text"
value={insertText}
onChange={(event) => setInsertText(event.target.value)}
placeholder={insertDialog?.type === "image" ? "توضیح کوتاه تصویر" : "متن قابل کلیک"}
className="text-right"
/>
</div>
<div className="flex flex-wrap justify-start gap-2 pt-2">
<Button type="submit" disabled={!insertUrl.trim()}>
{insertDialog?.type === "image" ? "درج تصویر" : "درج لینک"}
</Button>
<Button type="button" variant="outline" onClick={closeInsertDialog}>
انصراف
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import type { ComponentType } from "react";
import { CalendarDays, CircleUserRound, Home, Newspaper } from "lucide-react";
import { usePathname } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import { Link } from "@/lib/router";
import { cn } from "@/lib/utils";
type NavItem = {
key: string;
label: string;
href: string;
icon: ComponentType<{ className?: string }>;
matches: (pathname: string) => boolean;
};
const isProfileOrAuthPath = (pathname: string) =>
pathname === "/profile" ||
pathname.startsWith("/profile/") ||
pathname === "/auth" ||
pathname.startsWith("/auth/") ||
pathname.startsWith("/reset-password") ||
pathname.startsWith("/verify-email");
export default function MobileBottomNav() {
const pathname = usePathname() || "/";
const { isAuthenticated } = useAuth();
if (pathname.startsWith("/admin") || pathname === "/logout" || pathname.startsWith("/auth/google")) {
return null;
}
const items: NavItem[] = [
{
key: "home",
label: "خانه",
href: "/",
icon: Home,
matches: (current) => current === "/",
},
{
key: "events",
label: "رویدادها",
href: "/events",
icon: CalendarDays,
matches: (current) => current === "/events" || current.startsWith("/events/"),
},
{
key: "blog",
label: "بلاگ",
href: "/blog",
icon: Newspaper,
matches: (current) => current === "/blog" || current.startsWith("/blog/"),
},
{
key: "account",
label: isAuthenticated ? "پروفایل" : "حساب",
href: isAuthenticated ? "/profile" : "/auth",
icon: CircleUserRound,
matches: isProfileOrAuthPath,
},
];
return (
<div
className="fixed inset-x-0 z-50 px-4 md:hidden"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 0.9rem)" }}
>
<nav
aria-label="Mobile navigation"
className="mx-auto flex w-full max-w-sm items-center justify-between rounded-[1.75rem] border border-white/20 bg-background/70 px-2 py-2 shadow-[0_18px_60px_rgba(15,23,42,0.18)] backdrop-blur-2xl dark:border-white/10 dark:bg-slate-950/65"
dir="rtl"
>
{items.map((item) => {
const active = item.matches(pathname);
const Icon = item.icon;
return (
<Link
key={item.key}
to={item.href}
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-1 rounded-2xl px-2 py-2 text-[11px] font-medium transition-all",
active
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-white/30 hover:text-foreground dark:hover:bg-white/10",
)}
aria-current={active ? "page" : undefined}
>
<Icon className={cn("h-5 w-5", active ? "scale-105" : "")} />
<span>{item.label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,222 @@
"use client";
import { useEffect, useState } from "react";
import { Loader2, LogOut, ShieldCheck, Smartphone } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
import { api } from "@/lib/api";
import OtpCodeField from "@/components/OtpCodeField";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { resolveErrorMessage } from "@/lib/utils";
const toEnglishDigits = (value: string) =>
value
.replace(/[\u06F0-\u06F9]/g, (digit) => String(digit.charCodeAt(0) - 0x06f0))
.replace(/[\u0660-\u0669]/g, (digit) => String(digit.charCodeAt(0) - 0x0660))
.replace(/[^\d]/g, "");
export default function MobileVerificationGate() {
const { user, isAuthenticated, loading, refreshProfile, setUser, logout } = useAuth();
const { toast } = useToast();
const [mobile, setMobile] = useState("");
const [code, setCode] = useState("");
const [step, setStep] = useState<"collect" | "verify">("collect");
const [sending, setSending] = useState(false);
const [verifying, setVerifying] = useState(false);
const [cooldown, setCooldown] = useState(0);
useEffect(() => {
if (user?.mobile) {
setMobile(user.mobile);
setStep("verify");
}
}, [user?.mobile]);
useEffect(() => {
if (cooldown <= 0) {
return;
}
const timer = window.setTimeout(() => setCooldown((current) => current - 1), 1000);
return () => window.clearTimeout(timer);
}, [cooldown]);
const handleSendOtp = async () => {
try {
setSending(true);
const normalizedMobile = toEnglishDigits(mobile);
const response = await api.sendMobileVerificationOtp({ mobile: normalizedMobile });
setMobile(normalizedMobile);
setStep("verify");
setCooldown(Math.min(response.expires_in_seconds, 120));
toast({
title: "کد تأیید ارسال شد",
description: response.message,
variant: "success",
});
} catch (error: unknown) {
toast({
title: "خطا در ارسال کد",
description: resolveErrorMessage(error, "ارسال کد تأیید انجام نشد."),
variant: "destructive",
});
} finally {
setSending(false);
}
};
const handleVerify = async () => {
try {
setVerifying(true);
const profile = await api.verifyMobile({
mobile: toEnglishDigits(mobile),
code: toEnglishDigits(code),
});
setUser(profile);
await refreshProfile();
toast({
title: "شماره موبایل تأیید شد",
description: "از این پس می‌توانید با موبایل و کد پیامکی وارد شوید.",
variant: "success",
});
} catch (error: unknown) {
toast({
title: "کد نامعتبر است",
description: resolveErrorMessage(error, "تأیید شماره موبایل انجام نشد."),
variant: "destructive",
});
} finally {
setVerifying(false);
}
};
if (loading || !isAuthenticated || !user?.requires_mobile_verification) {
return null;
}
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-slate-950/70 px-4 backdrop-blur-md" dir="rtl">
<div className="w-full max-w-lg rounded-[2rem] border border-white/10 bg-background/95 p-6 shadow-[0_30px_80px_rgba(15,23,42,0.35)]">
<div className="mb-6 flex items-start justify-between gap-4">
<Button
type="button"
variant="outline"
className="rounded-2xl"
onClick={logout}
>
<LogOut className="ml-2 h-4 w-4" />
خروج
</Button>
<div className="text-right">
<div className="mb-3 inline-flex rounded-2xl border border-primary/20 bg-primary/10 p-3 text-primary">
<ShieldCheck className="h-5 w-5" />
</div>
<h2 className="text-xl font-semibold">تکمیل امنیت حساب با موبایل</h2>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
برای ادامه استفاده از سایت باید شماره موبایل خود را ثبت و با کد پیامکی تأیید کنید.
ورودهای بعدی و بازیابی حساب شما از همین مسیر انجام میشود.
</p>
</div>
</div>
<div className="rounded-[1.5rem] border border-border/70 bg-muted/20 p-4">
<div className="mb-4 flex items-center justify-end gap-3">
<div className="text-right">
<p className="font-medium">{user.first_name || user.last_name ? `${user.first_name} ${user.last_name}`.trim() : user.username}</p>
<p className="text-sm text-muted-foreground">
{user.email ? `ایمیل متصل: ${user.email}` : "ایمیل برای این حساب اختیاری است."}
</p>
</div>
<div className="rounded-2xl border border-border/70 bg-background/80 p-3">
<Smartphone className="h-5 w-5 text-primary" />
</div>
</div>
<div className="space-y-4">
<div>
<Label htmlFor="required-mobile" className="mb-2 block text-right">
شماره موبایل
</Label>
<Input
id="required-mobile"
type="tel"
dir="ltr"
inputMode="numeric"
value={mobile}
onChange={(event) => setMobile(toEnglishDigits(event.target.value))}
placeholder="09xxxxxxxxx"
className="h-12 rounded-2xl"
disabled={sending || verifying}
/>
</div>
{step === "verify" ? (
<div className="space-y-3">
<div>
<Label className="mb-3 block text-right">کد تأیید پیامکی</Label>
<OtpCodeField
value={code}
onChange={(value) => setCode(toEnglishDigits(value))}
disabled={verifying}
/>
</div>
<div className="flex flex-col gap-3 sm:flex-row-reverse">
<Button
type="button"
className="flex-1 rounded-2xl"
onClick={() => void handleVerify()}
disabled={verifying || code.length !== 5}
>
{verifying ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال تأیید...
</>
) : (
"تأیید و ادامه"
)}
</Button>
<Button
type="button"
variant="outline"
className="rounded-2xl"
onClick={() => void handleSendOtp()}
disabled={sending || cooldown > 0}
>
{sending ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
ارسال...
</>
) : cooldown > 0 ? (
`ارسال مجدد تا ${cooldown} ثانیه`
) : (
"ارسال مجدد کد"
)}
</Button>
</div>
</div>
) : (
<Button
type="button"
className="w-full rounded-2xl"
onClick={() => void handleSendOtp()}
disabled={sending || mobile.length !== 11}
>
{sending ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال ارسال کد...
</>
) : (
"ارسال کد تأیید"
)}
</Button>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -1,33 +1,37 @@
// src/components/ModeToggle.tsx
import { Button } from '@/components/ui/button';
import { Moon, Sun } from 'lucide-react';
import { useTheme } from '@/components/ThemeProvider';
import { Button } from "@/components/ui/button";
import { useTheme } from "@/components/ThemeProvider";
import { cn } from "@/lib/utils";
import { Moon, Sun } from "lucide-react";
export default function ModeToggle() {
export default function ModeToggle({ className }: { className?: string }) {
const { theme, setTheme } = useTheme();
const handleToggle = () => {
if (theme === 'system' && typeof window !== 'undefined') {
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
setTheme(prefersDark ? 'light' : 'dark');
if (theme === "system" && typeof window !== "undefined") {
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
setTheme(prefersDark ? "light" : "dark");
return;
}
setTheme(theme === 'dark' ? 'light' : 'dark');
setTheme(theme === "dark" ? "light" : "dark");
};
const isDark =
theme === 'dark' ||
(theme === 'system' &&
typeof document !== 'undefined' &&
document.documentElement.classList.contains('dark'));
theme === "dark" ||
(theme === "system" &&
typeof document !== "undefined" &&
document.documentElement.classList.contains("dark"));
const nextThemeLabel = isDark ? 'روشن' : 'تاریک';
const nextThemeLabel = isDark ? "روشن" : "تاریک";
return (
<Button
variant="outline"
variant="ghost"
size="icon"
className={cn(
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
className,
)}
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
title={`تغییر تم به حالت ${nextThemeLabel}`}
onClick={handleToggle}

View File

@@ -1,11 +1,14 @@
import { useMemo, useState } from 'react';
import { Link, NavLink, useNavigate } from 'react-router-dom';
import { Menu, ChevronDown } from 'lucide-react';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import ModeToggle from '@/components/ModeToggle';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
"use client";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
import { Link, NavLink } from "@/lib/router";
import { useAuth } from "@/contexts/AuthContext";
import ModeToggle from "@/components/ModeToggle";
import NotificationsBell from "@/components/NotificationsBell";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
@@ -13,190 +16,142 @@ import {
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
const NavItem = ({
to,
children,
onClick,
}: {
to: string;
children: React.ReactNode;
onClick?: () => void;
}) => (
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
<NavLink
to={to}
onClick={onClick}
className={({ isActive }) =>
[
'px-2 py-1 rounded-md transition-colors',
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
].join(' ')
cn(
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)
}
>
{children}
</NavLink>
);
export default function Navbar() {
const navigate = useNavigate();
function ProfileAvatarMenu() {
const { user, isAuthenticated } = useAuth();
const isAdminUser = isAuthenticated && ((user?.is_staff || user?.is_superuser) ?? false);
const [open, setOpen] = useState(false);
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
const avatarInitials = useMemo(
() => (user?.first_name?.[0] || user?.last_name?.[0] || user?.username?.[0] || '?').toUpperCase(),
() =>
(user?.first_name?.[0] ||
user?.last_name?.[0] ||
user?.username?.[0] ||
"?").toUpperCase(),
[user?.first_name, user?.last_name, user?.username],
);
const UserDropdown = () => (
<DropdownMenu>
if (!isAuthenticated) {
return (
<Link to="/auth">
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
</Link>
);
}
return (
<DropdownMenu dir="rtl">
<DropdownMenuTrigger asChild>
<button
type="button"
className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/40 px-2 py-1 pr-2.5 transition hover:bg-muted"
<Button
variant="ghost"
size="icon"
className="h-11 w-11 rounded-full border-0 bg-transparent p-0 shadow-none transition hover:bg-background/45"
aria-label="منوی حساب کاربری"
>
<Avatar className="h-9 w-9">
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
<Avatar className="h-10 w-10 border border-white/30 shadow-sm">
<AvatarImage
src={
user?.profile_picture_preview_url ||
user?.profile_picture ||
undefined
}
alt={user?.username || "profile"}
/>
<AvatarFallback>{avatarInitials}</AvatarFallback>
</Avatar>
<ChevronDown className="h-4 w-4 text-muted-foreground" />
</button>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56" dir="rtl">
<DropdownMenuLabel className="text-xs text-muted-foreground">
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
<DropdownMenuContent align="end" sideOffset={12} className="w-64 rounded-2xl p-2 text-right">
<DropdownMenuLabel className="text-right">
{[user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "حساب کاربری"}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to="/profile">پروفایل</Link>
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/profile">
مشاهده پروفایل
<UserRound className="h-4 w-4" />
</Link>
</DropdownMenuItem>
{isAdminUser && (
<DropdownMenuItem asChild>
<Link to="/admin">داشبورد مدیریت</Link>
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/reset-password">
تغییر یا بازیابی رمز
<RotateCcw className="h-4 w-4" />
</Link>
</DropdownMenuItem>
{isAdminUser ? (
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
<Link to="/admin">
داشبورد مدیریت
<LayoutDashboard className="h-4 w-4" />
</Link>
</DropdownMenuItem>
)}
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className="flex items-center justify-between gap-2"
>
<span>حالت نمایش</span>
<ModeToggle />
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault();
navigate('/logout');
}}
className="text-red-600 focus:text-red-600"
>
خروج
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive focus:text-destructive">
<Link to="/logout">
خروج از حساب
<LogOut className="h-4 w-4" />
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default function Navbar() {
const { isAuthenticated } = useAuth();
return (
<nav className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60" dir="rtl">
<nav
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
dir="rtl"
>
<div className="container mx-auto px-4 py-3">
<div className="flex flex-row-reverse items-center justify-between gap-3">
<Link to="/" className="order-2 flex items-center gap-2">
<span className="sm:inline text-2xl font-bold text-primary">
انجمن علمی کامپیوتر گیلان
</span>
<div className="flex items-center justify-between gap-4">
<Link to="/" className="flex min-w-0 items-center gap-3">
<div className="hidden rounded-2xl sm:flex">
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
</div>
<div className="min-w-0 text-right">
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
انجمن علمی مهندسی کامپیوتر
</p>
<p className="hidden text-xs text-muted-foreground sm:block">
دانشکدهی فنی و مهندسی شرق گیلان
</p>
</div>
</Link>
<div className="order-1 hidden md:flex items-center gap-2">
<div className="hidden items-center gap-2 md:flex">
<NavItem to="/">خانه</NavItem>
<NavItem to="/blog">بلاگ</NavItem>
<NavItem to="/events">رویدادها</NavItem>
{isAuthenticated ? (
<UserDropdown />
) : (
<>
<Link to="/auth">
<Button size="sm">ورود / ثبتنام</Button>
</Link>
<ModeToggle />
</>
)}
<ModeToggle />
{isAuthenticated ? <NotificationsBell /> : null}
<ProfileAvatarMenu />
</div>
<div className="order-1 md:hidden">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon" aria-label="U.U+U^">
<Menu className="h-5 w-5" />
</Button>
</SheetTrigger>
<SheetContent side="right" className="w-[80vw] sm:w-[360px]" dir="rtl">
<div className="mt-6 flex flex-col gap-4 text-right">
<Link
to="/"
onClick={() => setOpen(false)}
className="flex items-center gap-2"
>
<img src="/favicon.ico" alt="لوگو" className="h-8 w-auto" height={32} width={32} />
<span className="text-xl font-semibold text-primary">انجمن علمی کامپیوتر گیلان</span>
</Link>
<div className="grid gap-2">
<NavItem to="/" onClick={() => setOpen(false)}>خانه</NavItem>
<NavItem to="/blog" onClick={() => setOpen(false)}>بلاگ</NavItem>
<NavItem to="/events" onClick={() => setOpen(false)}>رویدادها</NavItem>
</div>
<div className="pt-4 border-t grid gap-3">
{isAuthenticated ? (
<>
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
<Avatar className="h-10 w-10">
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
<AvatarFallback>{avatarInitials}</AvatarFallback>
</Avatar>
<div className="flex-1 text-right">
<div className="font-medium">{user?.username}</div>
{user?.email ? <div className="text-xs text-muted-foreground">{user.email}</div> : null}
</div>
</div>
<div className="grid gap-2">
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
<Link to="/profile">پروفایل</Link>
</Button>
{isAdminUser && (
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
<Link to="/admin">داشبورد مدیریت</Link>
</Button>
)}
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">حالت نمایش</span>
<ModeToggle />
</div>
<Button
variant="outline"
className="justify-between text-red-600 border-red-600 hover:bg-red-50 dark:text-red-400 dark:border-red-400 dark:hover:bg-red-950/30"
onClick={() => { setOpen(false); navigate('/logout'); }}
>
خروج
</Button>
</div>
</>
) : (
<div className="grid gap-2">
<Link to="/auth" onClick={() => setOpen(false)}>
<Button className="w-full">ورود / ثبتنام</Button>
</Link>
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="text-sm text-muted-foreground">حالت نمایش</span>
<ModeToggle />
</div>
</div>
)}
</div>
</div>
</SheetContent>
</Sheet>
<div className="flex items-center gap-2 md:hidden">
{isAuthenticated ? <NotificationsBell /> : null}
<ModeToggle />
<ProfileAvatarMenu />
</div>
</div>
</div>

View File

@@ -0,0 +1,184 @@
"use client";
import { Bell, CheckCheck, Loader2, Trash2 } from "lucide-react";
import ConfirmAction from "@/components/ConfirmAction";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ScrollArea } from "@/components/ui/scroll-area";
import { useNotifications } from "@/contexts/NotificationsContext";
import type { NotificationSchema } from "@/lib/types";
import { cn, formatJalali } from "@/lib/utils";
const connectionLabels = {
idle: "خاموش",
connecting: "در حال اتصال",
connected: "متصل",
disconnected: "قطع شده",
} as const;
function NotificationItem({
notification,
onOpen,
onDelete,
}: {
notification: NotificationSchema;
onOpen: (notification: NotificationSchema) => Promise<unknown>;
onDelete: (notification: NotificationSchema) => Promise<unknown>;
}) {
return (
<div
className={cn(
"rounded-2xl border border-border/70 bg-background/75 p-3 text-right transition hover:border-primary/30 hover:bg-muted/35",
!notification.is_seen && "border-primary/30 bg-primary/5",
)}
>
<div className="flex items-start justify-between gap-3">
<ConfirmAction
title="حذف اعلان"
description="آیا از حذف این اعلان مطمئن هستید؟"
onConfirm={() => onDelete(notification)}
trigger={
<Button
type="button"
size="icon"
variant="ghost"
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
}
/>
<button
type="button"
onClick={() => void onOpen(notification)}
className="min-w-0 flex-1 space-y-1 text-right"
>
<div className="flex items-center justify-end gap-2">
{!notification.is_seen ? (
<span className="h-2.5 w-2.5 rounded-full bg-primary" />
) : null}
<p className="truncate font-semibold">{notification.title}</p>
</div>
<p className="line-clamp-2 text-sm text-muted-foreground">
{notification.message}
</p>
<p className="text-xs text-muted-foreground/80">
{formatJalali(notification.created_at, false)}
</p>
</button>
</div>
</div>
);
}
export default function NotificationsBell() {
const {
notifications,
unreadCount,
totalCount,
hasMore,
isLoading,
isLoadingMore,
connectionStatus,
loadMore,
markAllAsSeen,
deleteNotification,
openNotification,
} = useNotifications();
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="relative h-10 w-10 rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm"
aria-label="اعلان‌ها"
>
<Bell className="h-4 w-4" />
{unreadCount > 0 ? (
<span className="absolute -left-1 -top-1 flex min-w-5 items-center justify-center rounded-full bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
{unreadCount > 9 ? "9+" : unreadCount}
</span>
) : null}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[min(92vw,26rem)] rounded-[1.5rem] border border-border/70 bg-background/95 p-0 shadow-xl backdrop-blur-xl" align="end" sideOffset={12}>
<div className="border-b border-border/70 px-4 py-4 text-right">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Badge variant={connectionStatus === "connected" ? "default" : "secondary"}>
{connectionLabels[connectionStatus]}
</Badge>
{unreadCount > 0 ? (
<Button
type="button"
variant="ghost"
size="sm"
className="rounded-full"
onClick={() => void markAllAsSeen()}
>
<CheckCheck className="ml-2 h-4 w-4" />
خواندن همه
</Button>
) : null}
</div>
<div>
<p className="font-semibold">اعلانها</p>
<p className="mt-1 text-xs text-muted-foreground">
{totalCount > 0 ? `${totalCount} مورد ثبت شده` : "هنوز اعلانی ندارید."}
</p>
</div>
</div>
</div>
<ScrollArea className="max-h-[24rem] px-4 py-4">
<div className="space-y-3">
{isLoading ? (
<div className="flex items-center justify-center gap-2 py-10 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
در حال بارگذاری اعلانها...
</div>
) : notifications.length ? (
notifications.map((notification) => (
<NotificationItem
key={notification.id}
notification={notification}
onOpen={openNotification}
onDelete={deleteNotification}
/>
))
) : (
<div className="rounded-2xl border border-dashed border-border/70 bg-muted/20 px-4 py-10 text-center text-sm text-muted-foreground">
اعلان تازهای برای شما ثبت نشده است.
</div>
)}
</div>
</ScrollArea>
{hasMore ? (
<div className="border-t border-border/70 px-4 py-3">
<Button
type="button"
variant="secondary"
className="w-full rounded-2xl"
onClick={() => void loadMore()}
disabled={isLoadingMore}
>
{isLoadingMore ? (
<>
<Loader2 className="ml-2 h-4 w-4 animate-spin" />
در حال بارگذاری...
</>
) : (
"نمایش موارد بیشتر"
)}
</Button>
</div>
) : null}
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,42 @@
"use client";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { cn } from "@/lib/utils";
type OtpCodeFieldProps = {
value: string;
onChange: (value: string) => void;
disabled?: boolean;
length?: number;
className?: string;
};
export default function OtpCodeField({
value,
onChange,
disabled = false,
length = 5,
className,
}: OtpCodeFieldProps) {
return (
<InputOTP
dir="ltr"
maxLength={length}
value={value}
onChange={(nextValue) => onChange(nextValue.replace(/[^\d]/g, ""))}
disabled={disabled}
inputMode="numeric"
containerClassName={cn("w-full justify-center", className)}
>
<InputOTPGroup dir="ltr" className="justify-center">
{Array.from({ length }).map((_, index) => (
<InputOTPSlot
key={index}
index={index}
className="h-12 w-11 rounded-2xl border border-border/70 bg-background/80 text-base shadow-sm first:rounded-2xl first:border last:rounded-2xl"
/>
))}
</InputOTPGroup>
</InputOTP>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
type ProgressiveImageProps = {
src?: string | null;
blurSrc?: string | null;
alt: string;
wrapperClassName?: string;
className?: string;
fallbackSrc?: string;
sizes?: string;
loading?: "eager" | "lazy";
decoding?: "async" | "sync" | "auto";
onClick?: React.MouseEventHandler<HTMLImageElement>;
};
const DEFAULT_FALLBACK = "/placeholder.svg";
export default function ProgressiveImage({
src,
blurSrc,
alt,
wrapperClassName,
className,
fallbackSrc = DEFAULT_FALLBACK,
sizes,
loading = "lazy",
decoding = "async",
onClick,
}: ProgressiveImageProps) {
const resolvedSrc = src || blurSrc || fallbackSrc;
const [loaded, setLoaded] = React.useState(false);
React.useEffect(() => {
setLoaded(false);
}, [resolvedSrc]);
return (
<div className={cn("relative overflow-hidden bg-muted", wrapperClassName)}>
{!loaded && (
<>
{blurSrc ? (
<img
src={blurSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full scale-110 object-cover blur-2xl"
/>
) : null}
<div className="absolute inset-0 animate-pulse bg-muted/80" aria-hidden="true" />
</>
)}
<img
src={resolvedSrc}
alt={alt}
sizes={sizes}
loading={loading}
decoding={decoding}
onClick={onClick}
onLoad={() => setLoaded(true)}
className={cn(
"h-full w-full object-cover transition-opacity duration-300",
loaded ? "opacity-100" : "opacity-0",
className,
)}
/>
</div>
);
}

View File

@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { usePathname, useSearchParams } from "next/navigation";
import {
completeNavigationProgress,
subscribeNavigationProgress,
} from "@/lib/navigation-progress";
const START_VALUE = 18;
const MAX_ACTIVE_VALUE = 90;
export default function RouteProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const [visible, setVisible] = React.useState(false);
const [progress, setProgress] = React.useState(0);
const intervalRef = React.useRef<number | null>(null);
const finishTimeoutRef = React.useRef<number | null>(null);
const safetyTimeoutRef = React.useRef<number | null>(null);
const activeRef = React.useRef(false);
const routeKey = `${pathname ?? ""}?${searchParams?.toString() ?? ""}`;
const clearTimers = React.useCallback(() => {
if (intervalRef.current !== null) {
window.clearInterval(intervalRef.current);
intervalRef.current = null;
}
if (finishTimeoutRef.current !== null) {
window.clearTimeout(finishTimeoutRef.current);
finishTimeoutRef.current = null;
}
if (safetyTimeoutRef.current !== null) {
window.clearTimeout(safetyTimeoutRef.current);
safetyTimeoutRef.current = null;
}
}, []);
const finish = React.useCallback(() => {
if (!activeRef.current) {
return;
}
activeRef.current = false;
clearTimers();
setProgress(100);
finishTimeoutRef.current = window.setTimeout(() => {
setVisible(false);
setProgress(0);
}, 220);
}, [clearTimers]);
const start = React.useCallback(() => {
clearTimers();
activeRef.current = true;
setVisible(true);
setProgress(START_VALUE);
window.requestAnimationFrame(() => {
setProgress(28);
});
intervalRef.current = window.setInterval(() => {
setProgress((current) => {
if (current >= MAX_ACTIVE_VALUE) {
return current;
}
const delta = Math.max((MAX_ACTIVE_VALUE - current) * 0.14, 1.5);
return Math.min(MAX_ACTIVE_VALUE, current + delta);
});
}, 180);
safetyTimeoutRef.current = window.setTimeout(() => {
finish();
}, 12000);
}, [clearTimers, finish]);
React.useEffect(() => {
return subscribeNavigationProgress((event) => {
if (event === "start") {
start();
return;
}
finish();
});
}, [finish, start]);
React.useEffect(() => {
finish();
}, [finish, routeKey]);
React.useEffect(() => {
return () => {
clearTimers();
};
}, [clearTimers]);
return (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-x-0 top-0 z-[100] h-1 overflow-hidden"
>
<div
className={`absolute inset-0 transition-opacity duration-200 ${
visible ? "opacity-100" : "opacity-0"
}`}
>
<div
className="h-full origin-right bg-[linear-gradient(90deg,hsl(var(--primary))_0%,hsl(var(--route-progress))_100%)] shadow-[0_0_16px_hsl(var(--route-progress)/0.55)] transition-[transform] duration-200 ease-out"
style={{ transform: `scaleX(${progress / 100})` }}
/>
</div>
</div>
);
}

View File

@@ -1,36 +0,0 @@
import * as React from "react";
import { useLocation, useNavigationType } from "react-router-dom";
export default function ScrollToTop({
onlyOnPush = false, // if true, keeps scroll on back/forward (POP)
smooth = false, // smooth animation
}: { onlyOnPush?: boolean; smooth?: boolean }) {
const { pathname, hash } = useLocation();
const navType = useNavigationType(); // 'PUSH' | 'POP' | 'REPLACE'
React.useLayoutEffect(() => {
// If URL has a hash (#id), scroll to that element
if (hash) {
const el = document.getElementById(hash.slice(1));
if (el) {
el.scrollIntoView({ behavior: smooth ? "smooth" : "auto", block: "start" });
return;
}
}
// If you want to keep scroll when user hits back/forward:
if (onlyOnPush && navType === "POP") return;
window.scrollTo({ top: 0, left: 0, behavior: smooth ? "smooth" : "auto" });
}, [pathname, hash, navType, onlyOnPush, smooth]);
// Disable native restoration if you always want to control it
React.useEffect(() => {
if (!onlyOnPush && "scrollRestoration" in window.history) {
const prev = window.history.scrollRestoration;
window.history.scrollRestoration = "manual";
return () => { window.history.scrollRestoration = prev as "auto" | "manual"; };
}
}, [onlyOnPush]);
return null;
}

View File

@@ -1,56 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from 'react';
"use client";
type Theme = 'light' | 'dark' | 'system';
type Ctx = { theme: Theme; setTheme: (t: Theme) => void };
const ThemeContext = React.createContext<Ctx | null>(null);
export function ThemeProvider({
children,
defaultTheme = 'system',
storageKey = 'egce-theme',
}: {
children: React.ReactNode;
defaultTheme?: Theme;
storageKey?: string;
}) {
const [theme, setTheme] = React.useState<Theme>(() => {
try { return (localStorage.getItem(storageKey) as Theme) || defaultTheme; }
catch { return defaultTheme; }
});
React.useEffect(() => {
const root = document.documentElement;
const mql = window.matchMedia('(prefers-color-scheme: dark)');
const apply = (t: Theme) => {
const isDark = t === 'system' ? mql.matches : t === 'dark';
root.classList.toggle('dark', isDark);
};
apply(theme);
const onChange = () => theme === 'system' && apply('system');
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, [theme]);
React.useEffect(() => {
try {
localStorage.setItem(storageKey, theme);
} catch (error) {
console.warn('Unable to persist theme preference', error);
}
}, [theme, storageKey]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = React.useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
export { ThemeProvider, useTheme } from "next-themes";

View File

@@ -0,0 +1,200 @@
import { Skeleton } from "@/components/ui/skeleton";
export function BlogCardsSkeleton({ count = 6 }: { count?: number }) {
return (
<div className="grid gap-6 md:grid-cols-2 2xl:grid-cols-3" aria-hidden="true">
{Array.from({ length: count }).map((_, index) => (
<div
key={index}
className="overflow-hidden rounded-[2rem] border border-border/70 bg-card/85 shadow-sm"
>
<Skeleton className="aspect-[16/10] w-full rounded-t-[2rem] rounded-b-none" />
<div className="space-y-4 p-5">
<Skeleton className="h-7 w-11/12" />
<Skeleton className="h-7 w-3/4" />
<div className="space-y-2 pt-1">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-8/12" />
</div>
<Skeleton className="h-4 w-28" />
</div>
</div>
))}
</div>
);
}
export function BlogListingPageLoading() {
return (
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.32))]" dir="rtl">
<div className="container mx-auto px-4 py-10">
<Skeleton className="mb-8 aspect-[5/1.25] w-full rounded-[2rem] md:aspect-[6/1.25]" />
<div className="mb-8 flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
<div className="space-y-3 text-right">
<Skeleton className="h-5 w-36" />
<Skeleton className="h-11 w-28" />
<Skeleton className="h-5 w-full max-w-xl" />
<Skeleton className="h-5 w-10/12 max-w-lg" />
</div>
<div className="flex w-full max-w-md items-center gap-2">
<Skeleton className="h-12 flex-1 rounded-2xl" />
<Skeleton className="h-12 w-12 rounded-2xl xl:hidden" />
</div>
</div>
<div className="grid gap-8 xl:grid-cols-[18rem_minmax(0,1fr)] xl:items-start">
<aside className="hidden xl:block">
<div className="sticky top-24 space-y-4 rounded-[2rem] border border-border/70 bg-card/80 p-4 shadow-sm">
<Skeleton className="h-7 w-32" />
<Skeleton className="h-32 w-full rounded-3xl" />
<Skeleton className="h-28 w-full rounded-3xl" />
<Skeleton className="h-40 w-full rounded-3xl" />
</div>
</aside>
<BlogCardsSkeleton />
</div>
</div>
</div>
);
}
export function BlogDetailPageLoading() {
return (
<div className="min-h-screen bg-[linear-gradient(180deg,hsl(var(--background)),hsl(var(--muted)/0.28))]" dir="rtl">
<div className="container mx-auto px-4 py-8">
<div className="gap-8 xl:flex xl:items-start">
<aside className="sticky top-24 hidden w-72 shrink-0 xl:block">
<div className="rounded-[1.75rem] border border-border/70 bg-card/90 p-4 shadow-sm">
<Skeleton className="mb-4 h-6 w-36" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
<Skeleton className="h-4 w-8/12" />
<Skeleton className="h-4 w-9/12" />
</div>
</div>
</aside>
<main className="min-w-0 flex-1">
<article className="overflow-hidden rounded-[2.5rem] border border-border/70 bg-card/95">
<header className="space-y-6 p-5 md:p-8">
<Skeleton className="h-5 w-64" />
<div className="space-y-4">
<Skeleton className="h-12 w-11/12" />
<Skeleton className="h-12 w-8/12" />
<Skeleton className="h-5 w-full max-w-3xl" />
<Skeleton className="h-5 w-10/12 max-w-2xl" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-8 w-28 rounded-full" />
<Skeleton className="h-8 w-36 rounded-full" />
</div>
</header>
<div className="px-5 md:px-8">
<Skeleton className="aspect-[16/9] w-full rounded-[2rem]" />
</div>
<div className="space-y-5 p-5 md:p-8 xl:hidden">
<Skeleton className="h-32 w-full rounded-[1.5rem]" />
</div>
<div className="space-y-8 px-5 pb-8 pt-6 md:px-8 md:pb-10">
<div className="mx-auto max-w-4xl space-y-4">
<Skeleton className="h-8 w-7/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-9/12" />
<Skeleton className="mt-8 h-8 w-6/12" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-10/12" />
</div>
<div className="mx-auto flex max-w-4xl flex-wrap gap-2">
<Skeleton className="h-8 w-20 rounded-full" />
<Skeleton className="h-8 w-24 rounded-full" />
<Skeleton className="h-8 w-16 rounded-full" />
</div>
<Skeleton className="mx-auto h-12 w-full max-w-4xl rounded-2xl" />
</div>
</article>
<Skeleton className="mt-8 h-48 w-full rounded-[2rem]" />
<Skeleton className="mt-10 h-56 w-full rounded-[2rem]" />
<Skeleton className="mt-10 h-72 w-full rounded-[2rem]" />
</main>
</div>
</div>
</div>
);
}
export function ListingPageLoading() {
return (
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<Skeleton className="mb-8 h-10 w-40" />
<Skeleton className="mb-8 h-10 w-full max-w-md" />
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="overflow-hidden rounded-lg border bg-card"
>
<Skeleton className="aspect-video w-full rounded-none" />
<div className="space-y-4 p-6">
<div className="flex items-start justify-between gap-3">
<Skeleton className="h-6 flex-1" />
<Skeleton className="h-6 w-16" />
</div>
<Skeleton className="h-4 w-32" />
<div className="space-y-3 pt-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-5/6" />
<Skeleton className="h-10 w-full" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}
export function DetailPageLoading() {
return (
<div className="min-h-screen bg-background" dir="rtl">
<div className="container mx-auto px-4 py-8">
<div className="mb-6 flex items-center justify-between gap-3">
<Skeleton className="h-10 w-36" />
<Skeleton className="h-5 w-28" />
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<Skeleton className="aspect-video w-full rounded-none" />
<div className="space-y-6 p-6">
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
</div>
<Skeleton className="h-10 w-4/5" />
<Skeleton className="h-4 w-40" />
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-5/6" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<Skeleton className="h-28 w-full" />
<Skeleton className="h-28 w-full" />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import * as React from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TooltipProvider } from "@/components/ui/tooltip";
import { Toaster } from "@/components/ui/toaster";
import { Toaster as Sonner } from "@/components/ui/sonner";
import { ThemeProvider } from "@/components/ThemeProvider";
import MobileVerificationGate from "@/components/MobileVerificationGate";
import { AuthProvider } from "@/contexts/AuthContext";
import { NotificationsProvider } from "@/contexts/NotificationsContext";
export default function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = React.useState(() => new QueryClient());
return (
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
storageKey="egce-theme"
>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<NotificationsProvider>
<TooltipProvider>
<Toaster />
<Sonner />
{children}
<MobileVerificationGate />
</TooltipProvider>
</NotificationsProvider>
</AuthProvider>
</QueryClientProvider>
</ThemeProvider>
);
}

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
import { cva } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { VariantProps, cva } from "class-variance-authority";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import { useTheme } from "next-themes";
import { Toaster as Sonner, toast } from "sonner";

View File

@@ -1,4 +1,3 @@
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";

View File

@@ -1,5 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
import { api } from '@/lib/api';
import type { UserProfileSchema } from '@/lib/types';
@@ -8,47 +7,83 @@ type User = UserProfileSchema;
interface AuthContextType {
user: User | null;
loading: boolean;
login: (email: string, password: string) => Promise<void>;
login: (identifier: string, password: string) => Promise<void>;
loginWithOtp: (mobile: string, code: string) => Promise<void>;
setSessionTokens: (accessToken: string, refreshToken: string) => Promise<void>;
refreshProfile: () => Promise<User | null>;
setUser: (user: User | null) => void;
logout: () => void;
isAuthenticated: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
const ACCESS_TOKEN_KEY = 'access_token';
const REFRESH_TOKEN_KEY = 'refresh_token';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
checkAuth();
const clearSession = useCallback(() => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
setUser(null);
}, []);
const checkAuth = async () => {
const token = localStorage.getItem('access_token');
if (token) {
try {
const profile = await api.getProfile();
setUser(profile as User);
} catch (error) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
}
const refreshProfile = useCallback(async () => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (!token) {
setUser(null);
setLoading(false);
return null;
}
setLoading(false);
};
const login = async (email: string, password: string) => {
const response = await api.login({ email, password });
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
await checkAuth();
};
try {
const profile = await api.getProfile();
setUser(profile as User);
return profile as User;
} catch {
clearSession();
return null;
} finally {
setLoading(false);
}
}, [clearSession]);
const logout = () => {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
setUser(null);
};
useEffect(() => {
void refreshProfile();
}, [refreshProfile]);
const setSessionTokens = useCallback(
async (accessToken: string, refreshToken: string) => {
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
setLoading(true);
await refreshProfile();
},
[refreshProfile],
);
const login = useCallback(
async (identifier: string, password: string) => {
const response = await api.login({ identifier, password });
await setSessionTokens(response.access_token, response.refresh_token);
},
[setSessionTokens],
);
const loginWithOtp = useCallback(
async (mobile: string, code: string) => {
const response = await api.loginWithOtp({ mobile, code });
await setSessionTokens(response.access_token, response.refresh_token);
},
[setSessionTokens],
);
const logout = useCallback(() => {
clearSession();
}, [clearSession]);
return (
<AuthContext.Provider
@@ -56,6 +91,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
user,
loading,
login,
loginWithOtp,
setSessionTokens,
refreshProfile,
setUser,
logout,
isAuthenticated: !!user,
}}

View File

@@ -0,0 +1,388 @@
"use client";
import type { ReactNode } from "react";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
import { api } from "@/lib/api";
import type {
NotificationDeleteResponseSchema,
NotificationListSchema,
NotificationSchema,
NotificationSeenResponseSchema,
} from "@/lib/types";
type ConnectionStatus = "idle" | "connecting" | "connected" | "disconnected";
type NotificationsContextValue = {
notifications: NotificationSchema[];
unreadCount: number;
totalCount: number;
hasMore: boolean;
isLoading: boolean;
isLoadingMore: boolean;
connectionStatus: ConnectionStatus;
refreshNotifications: () => Promise<void>;
loadMore: () => Promise<void>;
markAsSeen: (notification: NotificationSchema) => Promise<NotificationSeenResponseSchema | null>;
deleteNotification: (notification: NotificationSchema) => Promise<NotificationDeleteResponseSchema | null>;
markAllAsSeen: () => Promise<void>;
openNotification: (notification: NotificationSchema) => Promise<void>;
};
const NotificationsContext = createContext<NotificationsContextValue | undefined>(undefined);
const PAGE_SIZE = 12;
const mergeNotifications = (
current: NotificationSchema[],
incoming: NotificationSchema[],
) => {
const byId = new Map<string, NotificationSchema>();
for (const notification of current) {
byId.set(notification.id, notification);
}
for (const notification of incoming) {
const existing = byId.get(notification.id);
byId.set(notification.id, existing ? { ...existing, ...notification } : notification);
}
return Array.from(byId.values()).sort((left, right) => {
return new Date(right.created_at).getTime() - new Date(left.created_at).getTime();
});
};
const openNotificationTarget = (notification: NotificationSchema) => {
if (typeof window === "undefined" || !notification.action_url) {
return;
}
const targetUrl = notification.action_url;
if (/^https?:\/\//i.test(targetUrl)) {
window.open(targetUrl, "_blank", "noopener,noreferrer");
return;
}
window.location.assign(targetUrl);
};
export function NotificationsProvider({ children }: { children: ReactNode }) {
const { isAuthenticated } = useAuth();
const [notifications, setNotifications] = useState<NotificationSchema[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [connectionStatus, setConnectionStatus] = useState<ConnectionStatus>("idle");
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<number | null>(null);
const reconnectAttemptRef = useRef(0);
const shownToastIdsRef = useRef<Set<string>>(new Set());
const hasMore = notifications.length < totalCount;
const cleanupStream = useCallback(() => {
if (reconnectTimeoutRef.current !== null) {
window.clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
eventSourceRef.current?.close();
eventSourceRef.current = null;
}, []);
const applyNotificationList = useCallback((payload: NotificationListSchema) => {
setNotifications(payload.notifications);
setUnreadCount(payload.unread_count);
setTotalCount(payload.count);
}, []);
const refreshNotifications = useCallback(async () => {
if (!isAuthenticated) {
setNotifications([]);
setUnreadCount(0);
setTotalCount(0);
setConnectionStatus("idle");
return;
}
setIsLoading(true);
try {
const payload = await api.getNotifications({ limit: PAGE_SIZE, offset: 0 });
applyNotificationList(payload);
} finally {
setIsLoading(false);
}
}, [applyNotificationList, isAuthenticated]);
const loadMore = useCallback(async () => {
if (!isAuthenticated || isLoadingMore || !hasMore) {
return;
}
setIsLoadingMore(true);
try {
const payload = await api.getNotifications({
limit: PAGE_SIZE,
offset: notifications.length,
});
setNotifications((current) => mergeNotifications(current, payload.notifications));
setUnreadCount(payload.unread_count);
setTotalCount(payload.count);
} finally {
setIsLoadingMore(false);
}
}, [hasMore, isAuthenticated, isLoadingMore, notifications.length]);
const markAsSeen = useCallback(async (notification: NotificationSchema) => {
if (notification.is_seen) {
return null;
}
const payload = await api.markNotificationSeen(notification.id);
if (payload.deleted) {
setNotifications((current) => current.filter((item) => item.id !== notification.id));
setTotalCount((current) => Math.max(current - 1, 0));
} else {
setNotifications((current) =>
current.map((item) =>
item.id === notification.id ? { ...item, is_seen: true } : item,
),
);
}
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
} else {
setUnreadCount((current) => Math.max(current - 1, 0));
}
return payload;
}, []);
const deleteNotification = useCallback(async (notification: NotificationSchema) => {
const payload = await api.deleteNotification(notification.id);
setNotifications((current) => current.filter((item) => item.id !== notification.id));
setTotalCount((current) => Math.max(current - 1, 0));
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
} else if (!notification.is_seen) {
setUnreadCount((current) => Math.max(current - 1, 0));
}
return payload;
}, []);
const markAllAsSeen = useCallback(async () => {
if (!isAuthenticated) {
return;
}
await api.markAllNotificationsRead();
setUnreadCount(0);
setNotifications((current) =>
current.map((notification) => ({ ...notification, is_seen: true })),
);
}, [isAuthenticated]);
const openNotification = useCallback(async (notification: NotificationSchema) => {
await markAsSeen(notification);
openNotificationTarget(notification);
}, [markAsSeen]);
const announceIncomingNotification = useCallback((notification: NotificationSchema) => {
if (notification.is_seen || shownToastIdsRef.current.has(notification.id)) {
return;
}
shownToastIdsRef.current.add(notification.id);
toast({
title: notification.title,
description: notification.message,
variant:
notification.level === "error"
? "destructive"
: notification.level === "success"
? "success"
: "default",
});
}, []);
const connectToStream = useCallback(async () => {
if (!isAuthenticated) {
cleanupStream();
setConnectionStatus("idle");
return;
}
cleanupStream();
setConnectionStatus("connecting");
try {
const tokenPayload = await api.issueNotificationStreamToken();
const stream = new EventSource(api.buildNotificationStreamUrl(tokenPayload.token));
eventSourceRef.current = stream;
stream.onopen = () => {
reconnectAttemptRef.current = 0;
setConnectionStatus("connected");
};
stream.addEventListener("connected", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as {
notifications?: NotificationSchema[];
unread_count?: number;
};
if (Array.isArray(payload.notifications)) {
setNotifications((current) => mergeNotifications(current, payload.notifications || []));
setTotalCount((current) => Math.max(current, payload.notifications?.length || 0));
}
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
}
});
stream.addEventListener("notification", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as {
notification?: NotificationSchema;
unread_count?: number;
};
const incomingNotification = payload.notification;
if (!incomingNotification) {
return;
}
setNotifications((current) => {
const exists = current.some((item) => item.id === incomingNotification.id);
if (!exists) {
setTotalCount((count) => count + 1);
}
return mergeNotifications(current, [incomingNotification]);
});
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
}
announceIncomingNotification(incomingNotification);
});
stream.addEventListener("notification_seen", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as {
notification_id?: string;
notification?: NotificationSchema | null;
deleted?: boolean;
unread_count?: number;
};
if (payload.deleted && payload.notification_id) {
setNotifications((current) =>
current.filter((item) => item.id !== payload.notification_id),
);
setTotalCount((current) => Math.max(current - 1, 0));
} else if (payload.notification) {
setNotifications((current) => mergeNotifications(current, [payload.notification!]));
} else if (payload.notification_id) {
setNotifications((current) =>
current.map((item) =>
item.id === payload.notification_id ? { ...item, is_seen: true } : item,
),
);
}
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
}
});
stream.addEventListener("notification_mark_all_read", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as {
unread_count?: number;
};
setNotifications((current) =>
current.map((notification) => ({ ...notification, is_seen: true })),
);
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
}
});
stream.addEventListener("unread_count", (event) => {
const payload = JSON.parse((event as MessageEvent<string>).data) as {
unread_count?: number;
};
if (typeof payload.unread_count === "number") {
setUnreadCount(payload.unread_count);
}
});
stream.onerror = () => {
stream.close();
eventSourceRef.current = null;
setConnectionStatus("disconnected");
reconnectAttemptRef.current += 1;
const delay = Math.min(1000 * 2 ** reconnectAttemptRef.current, 30000);
reconnectTimeoutRef.current = window.setTimeout(() => {
void connectToStream();
}, delay);
};
} catch {
setConnectionStatus("disconnected");
}
}, [announceIncomingNotification, cleanupStream, isAuthenticated]);
useEffect(() => {
if (!isAuthenticated) {
cleanupStream();
setNotifications([]);
setUnreadCount(0);
setTotalCount(0);
setConnectionStatus("idle");
return;
}
void refreshNotifications();
void connectToStream();
return () => {
cleanupStream();
};
}, [cleanupStream, connectToStream, isAuthenticated, refreshNotifications]);
const value = useMemo<NotificationsContextValue>(() => ({
notifications,
unreadCount,
totalCount,
hasMore,
isLoading,
isLoadingMore,
connectionStatus,
refreshNotifications,
loadMore,
markAsSeen,
deleteNotification,
markAllAsSeen,
openNotification,
}), [
connectionStatus,
deleteNotification,
hasMore,
isLoading,
isLoadingMore,
loadMore,
markAllAsSeen,
markAsSeen,
notifications,
openNotification,
refreshNotifications,
totalCount,
unreadCount,
]);
return (
<NotificationsContext.Provider value={value}>
{children}
</NotificationsContext.Provider>
);
}
export function useNotifications() {
const context = useContext(NotificationsContext);
if (!context) {
throw new Error("useNotifications must be used within a NotificationsProvider");
}
return context;
}

View File

@@ -2,10 +2,6 @@
@tailwind components;
@tailwind utilities;
* {
font-family: 'Vazirmatn', sans-serif;
}
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
All colors MUST be HSL.
*/
@@ -57,6 +53,7 @@ All colors MUST be HSL.
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--route-progress: 199 89% 48%;
}
.dark {
@@ -95,15 +92,20 @@ All colors MUST be HSL.
--sidebar-accent-foreground: 240 4.8% 95.9%;
--sidebar-border: 240 3.7% 15.9%;
--sidebar-ring: 217.2 91.2% 59.8%;
--route-progress: 198 93% 60%;
}
}
@layer base {
html {
scroll-behavior: smooth;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
@apply bg-background font-sans text-foreground;
}
}

View File

@@ -1,7 +1,8 @@
import type * as Types from './types';
import { apiBaseUrl } from '@/lib/site';
const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
apiBaseUrl;
type ApiErrorBody = {
error?: string;
@@ -18,8 +19,32 @@ class ApiClient {
this.baseUrl = baseUrl;
}
private getStorageValue(key: string) {
if (typeof window === 'undefined') {
return null;
}
return window.localStorage.getItem(key);
}
private setStorageValue(key: string, value: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, value);
}
private removeStorageValue(key: string) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.removeItem(key);
}
private getAuthHeaders(): HeadersInit {
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
return {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
@@ -27,7 +52,7 @@ class ApiClient {
}
private async refreshAccessToken(): Promise<string> {
const refreshToken = localStorage.getItem('refresh_token');
const refreshToken = this.getStorageValue('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token available');
}
@@ -39,14 +64,14 @@ class ApiClient {
});
if (!response.ok) {
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
this.removeStorageValue('access_token');
this.removeStorageValue('refresh_token');
throw new Error('Session expired. Please login again.');
}
const data: Types.TokenSchema = await response.json();
localStorage.setItem('access_token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
this.setStorageValue('access_token', data.access_token);
this.setStorageValue('refresh_token', data.refresh_token);
return data.access_token;
}
@@ -75,7 +100,7 @@ class ApiClient {
const response = await fetch(url, config);
// Handle 401 with automatic token refresh
if (response.status === 401 && localStorage.getItem('refresh_token')) {
if (response.status === 401 && this.getStorageValue('refresh_token')) {
if (!this.isRefreshing) {
this.isRefreshing = true;
try {
@@ -154,6 +179,27 @@ class ApiClient {
});
}
async loginWithOtp(data: Types.UserOtpLoginSchema) {
return this.request<Types.TokenSchema>('/api/auth/login/otp', {
method: 'POST',
body: JSON.stringify(data),
});
}
async sendOtp(data: Types.OtpSendSchema) {
return this.request<Types.OtpSendResponseSchema>('/api/auth/otp/send', {
method: 'POST',
body: JSON.stringify(data),
});
}
async verifyRegisterOtp(data: Types.RegisterOtpVerifySchema) {
return this.request<Types.MessageSchema>('/api/auth/otp/verify-register', {
method: 'POST',
body: JSON.stringify(data),
});
}
async refreshToken(data: Types.TokenRefreshIn) {
return this.request<Types.TokenSchema>('/api/auth/refresh', {
method: 'POST',
@@ -161,6 +207,60 @@ class ApiClient {
});
}
async resetPassword(data: Types.PasswordResetSchema) {
return this.request<Types.MessageSchema>('/api/auth/reset-password', {
method: 'POST',
body: JSON.stringify(data),
});
}
async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) {
return this.request<Types.OtpSendResponseSchema>('/api/auth/mobile/send-otp', {
method: 'POST',
body: JSON.stringify(data),
});
}
async verifyMobile(data: Types.MobileOtpVerifySchema) {
return this.request<Types.UserProfileSchema>('/api/auth/mobile/verify', {
method: 'POST',
body: JSON.stringify(data),
});
}
async startGoogleLogin() {
if (typeof window !== 'undefined') {
window.location.href = `${this.baseUrl}/api/auth/oauth/google/start`;
}
}
async getGoogleFlow(flow: string) {
return this.request<Types.GoogleFlowResponseSchema>(
`/api/auth/oauth/google/flow?flow=${encodeURIComponent(flow)}`
);
}
async completeGoogleSignup(data: Types.GoogleCompleteSchema) {
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/complete', {
method: 'POST',
body: JSON.stringify(data),
});
}
async resendGoogleClaimOtp(flow: string) {
return this.request<Types.MessageSchema>('/api/auth/oauth/google/claim/send-otp', {
method: 'POST',
body: JSON.stringify({ flow }),
});
}
async verifyGoogleClaim(flow: string, code: string) {
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/claim/verify', {
method: 'POST',
body: JSON.stringify({ flow, code }),
});
}
async verifyEmail(token: string): Promise<Types.MessageSchema> {
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
const response = await fetch(url, { method: 'GET' });
@@ -184,7 +284,6 @@ class ApiClient {
}
async getProfile() {
const token = localStorage.getItem('access_token');
return this.request<Types.UserProfileSchema>('/api/auth/profile'
);
}
@@ -200,7 +299,7 @@ class ApiClient {
const formData = new FormData();
formData.append('file', file);
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
method: 'POST',
headers: {
@@ -241,6 +340,17 @@ class ApiClient {
});
}
async getLegacyVerifyEmailMessage(token: string) {
return this.request<Types.MessageSchema>(`/api/auth/verify-email/${encodeURIComponent(token)}`);
}
async getLegacyResetTokenMessage(token: string) {
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
method: 'POST',
body: JSON.stringify({ token }),
});
}
async checkUsername(username: string) {
return this.request<Types.UsernameCheckSchema>(
@@ -248,6 +358,12 @@ class ApiClient {
);
}
async checkMobile(mobile: string) {
return this.request<Types.MobileLookupSchema>(
`/api/auth/check-mobile?mobile=${encodeURIComponent(mobile)}`
);
}
// Admin auth endpoints
async listDeletedUsers() {
return this.request<Types.UserProfileSchema[]>('/api/auth/users/deleted');
@@ -281,50 +397,283 @@ class ApiClient {
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
}
async getUserDetail(userId: number) {
return this.request<Types.UserProfileSchema>(`/api/auth/users/${userId}`);
}
async listAuthorizationRoles() {
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
}
async getUserAuthorization(userId: number) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`);
}
async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) {
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async getAdminDashboard(params?: {
date_from?: string;
date_to?: string;
event_id?: number;
granularity?: 'auto' | 'day' | 'week' | 'month';
}) {
const query = new URLSearchParams();
if (params?.date_from) query.set('date_from', params.date_from);
if (params?.date_to) query.set('date_to', params.date_to);
if (params?.event_id != null) query.set('event_id', String(params.event_id));
if (params?.granularity) query.set('granularity', params.granularity);
return this.request<Types.AdminDashboardAnalyticsSchema>(
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
);
}
async getAdminUserAnalytics(params?: { date_from?: string; date_to?: string }) {
const query = new URLSearchParams();
if (params?.date_from) query.set('date_from', params.date_from);
if (params?.date_to) query.set('date_to', params.date_to);
return this.request<Types.UserAnalyticsSchema>(
`/api/analytics/admin/users${query.toString() ? `?${query.toString()}` : ''}`,
);
}
async getAdminEventAnalytics(params?: { date_from?: string; date_to?: string; event_id?: number }) {
const query = new URLSearchParams();
if (params?.date_from) query.set('date_from', params.date_from);
if (params?.date_to) query.set('date_to', params.date_to);
if (params?.event_id != null) query.set('event_id', String(params.event_id));
return this.request<Types.EventAnalyticsSchema>(
`/api/analytics/admin/events${query.toString() ? `?${query.toString()}` : ''}`,
);
}
async getAdminBlogAnalytics(params?: { date_from?: string; date_to?: string }) {
const query = new URLSearchParams();
if (params?.date_from) query.set('date_from', params.date_from);
if (params?.date_to) query.set('date_to', params.date_to);
return this.request<Types.BlogAnalyticsSchema>(
`/api/analytics/admin/blog${query.toString() ? `?${query.toString()}` : ''}`,
);
}
async getAdminDashboardEventOptions(params?: { search?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.AnalyticsEventOptionsSchema>(
`/api/analytics/admin/events/options${query.toString() ? `?${query.toString()}` : ''}`,
);
}
// ============= Blog Endpoints =============
async getPosts(params?: {
page?: number;
limit?: number;
category?: string;
tag?: string;
tag?: string | string[];
search?: string;
featured?: boolean;
author?: string;
author?: string | string[];
}) {
const queryParams = new URLSearchParams();
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.category) queryParams.append('category', params.category);
if (params?.tag) queryParams.append('tag', params.tag);
if (Array.isArray(params?.tag)) {
params.tag.forEach((tag) => queryParams.append('tag', tag));
} else if (params?.tag) {
queryParams.append('tag', params.tag);
}
if (params?.search) queryParams.append('search', params.search);
if (params?.featured !== undefined) queryParams.append('featured', params.featured.toString());
if (params?.author) queryParams.append('author', params.author);
if (Array.isArray(params?.author)) {
params.author.forEach((author) => queryParams.append('author', author));
} else if (params?.author) {
queryParams.append('author', params.author);
}
const query = queryParams.toString();
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
}
async getBlogFilters() {
return this.request<Types.BlogFiltersSchema>('/api/blog/filters');
}
async getPost(slug: string) {
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`);
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
}
async createPost(data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>('/api/blog/posts', {
return this.request<Types.PostDetailSchema>('/api/blog/admin/posts', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updatePost(slug: string, data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, {
async updatePost(postId: number, data: Types.PostCreateSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async listAdminBlogPosts(params?: {
status?: string;
search?: string;
mine?: boolean;
limit?: number;
offset?: number;
}) {
const query = new URLSearchParams();
if (params?.status) query.set('status', params.status);
if (params?.search) query.set('search', params.search);
if (params?.mine != null) query.set('mine', String(params.mine));
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PostListSchema[]>(`/api/blog/admin/posts${query.toString() ? `?${query.toString()}` : ''}`);
}
async listBlogWriters() {
return this.request<NonNullable<Types.PostListSchema['writers']>>('/api/blog/admin/writers');
}
async getAdminBlogPost(postId: number) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`);
}
async submitBlogPost(postId: number) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/submit`, {
method: 'POST',
});
}
async reviewBlogPost(postId: number, data: Types.PostReviewSchema) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/review`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async listBlogPostAssets(postId: number) {
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`);
}
async uploadBlogPostFeaturedImage(postId: number, file: File) {
const formData = new FormData();
formData.append('file', file);
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/featured-image`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(error.error || error.detail || 'Featured image upload failed');
}
return response.json() as Promise<Types.PostDetailSchema>;
}
async deleteBlogPostFeaturedImage(postId: number) {
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/featured-image`, {
method: 'DELETE',
});
}
async uploadBlogPostAsset(
postId: number,
file: File,
data: { title?: string; alt_text?: string; caption?: string } = {},
) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', data.title ?? '');
formData.append('alt_text', data.alt_text ?? '');
formData.append('caption', data.caption ?? '');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/assets`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(error.error || error.detail || 'Asset upload failed');
}
return response.json() as Promise<Types.PostAssetSchema>;
}
uploadBlogPostAssetWithProgress(
postId: number,
file: File,
data: { title?: string; alt_text?: string; caption?: string } = {},
onProgress?: (progress: number) => void,
) {
const formData = new FormData();
formData.append('file', file);
formData.append('title', data.title ?? '');
formData.append('alt_text', data.alt_text ?? '');
formData.append('caption', data.caption ?? '');
const token = this.getStorageValue('access_token');
return new Promise<Types.PostAssetSchema>((resolve, reject) => {
const request = new XMLHttpRequest();
request.open('POST', `${this.baseUrl}/api/blog/admin/posts/${postId}/assets`);
if (token) {
request.setRequestHeader('Authorization', `Bearer ${token}`);
}
request.upload.onprogress = (event) => {
if (!event.lengthComputable) return;
onProgress?.(Math.round((event.loaded / event.total) * 100));
};
request.onload = () => {
let body: (Types.PostAssetSchema & ApiErrorBody) | null = null;
try {
body = request.responseText ? JSON.parse(request.responseText) as Types.PostAssetSchema & ApiErrorBody : null;
} catch {
body = null;
}
if (request.status >= 200 && request.status < 300 && body) {
onProgress?.(100);
resolve(body);
return;
}
reject(new Error(body?.error || body?.detail || 'Asset upload failed'));
};
request.onerror = () => reject(new Error('Asset upload failed'));
request.send(formData);
});
}
async deleteBlogPostAsset(postId: number, assetId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
method: 'DELETE',
});
}
async deletePost(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, {
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`, {
method: 'DELETE',
});
}
@@ -341,16 +690,43 @@ class ApiClient {
// Comments
async getComments(slug: string) {
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${slug}/comments`);
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`);
}
async createComment(slug: string, data: Types.CommentCreateSchema) {
return this.request<Types.CommentSchema>(`/api/blog/posts/${slug}/comments`, {
return this.request<Types.CommentSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`, {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateComment(commentId: number, data: Types.CommentUpdateSchema) {
return this.request<Types.CommentSchema>(`/api/blog/comments/${commentId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async hideComment(commentId: number, note?: string) {
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/hide`, {
method: 'POST',
body: JSON.stringify({ note: note ?? '' }),
});
}
async unhideComment(commentId: number) {
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/unhide`, {
method: 'POST',
});
}
async deleteComment(commentId: number, note?: string) {
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/delete`, {
method: 'POST',
body: JSON.stringify({ note: note ?? '' }),
});
}
async listDeletedComments() {
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
}
@@ -363,13 +739,27 @@ class ApiClient {
// Likes
async toggleLike(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/like`, {
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/like`, {
method: 'POST',
});
}
async toggleSave(slug: string) {
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/save`, {
method: 'POST',
});
}
async getBlogInteraction(slug: string) {
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/interaction`);
}
async getLikesCount(slug: string) {
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`);
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/likes`);
}
async getMyBlogActivity() {
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
}
// Categories
@@ -377,6 +767,30 @@ class ApiClient {
return this.request<Types.CategorySchema[]>('/api/blog/categories');
}
async listAdminCategories() {
return this.request<Types.AdminCategorySchema[]>('/api/blog/admin/categories');
}
async createCategory(data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>('/api/blog/admin/categories', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) {
return this.request<Types.AdminCategorySchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteCategory(categoryId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/categories/${categoryId}`, {
method: 'DELETE',
});
}
async getCategory(slug: string) {
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
}
@@ -396,6 +810,30 @@ class ApiClient {
return this.request<Types.TagSchema[]>('/api/blog/tags');
}
async listAdminTags() {
return this.request<Types.AdminTagSchema[]>('/api/blog/admin/tags');
}
async createTag(data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>('/api/blog/admin/tags', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateTag(tagId: number, data: Types.TagWriteSchema) {
return this.request<Types.AdminTagSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteTag(tagId: number) {
return this.request<Types.MessageSchema>(`/api/blog/admin/tags/${tagId}`, {
method: 'DELETE',
});
}
async getTag(slug: string) {
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
}
@@ -469,13 +907,75 @@ class ApiClient {
}
async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
return this.request<Types.EventSchema>(`/api/events/${eventId}`, {
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async createEvent(data: Types.EventCreateSchema) {
return this.request<Types.EventDetailSchema>('/api/events/', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async uploadEventFeaturedImage(eventId: number, file: File) {
const formData = new FormData();
formData.append('file', file);
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/featured-image`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(body.error || body.detail || 'Event image upload failed');
}
return response.json() as Promise<Types.EventDetailSchema>;
}
async deleteEventFeaturedImage(eventId: number) {
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}/featured-image`, {
method: 'DELETE',
});
}
async listEventGallery(eventId: number) {
return this.request<Types.EventGalleryItem[]>(`/api/events/${eventId}/gallery`);
}
async uploadEventGalleryImage(eventId: number, file: File, data: { title?: string; alt_text?: string } = {}) {
const formData = new FormData();
formData.append('file', file);
if (data.title) formData.append('title', data.title);
if (data.alt_text) formData.append('alt_text', data.alt_text);
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/gallery`, {
method: 'POST',
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: formData,
});
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
throw new Error(body.error || body.detail || 'Event gallery upload failed');
}
return response.json() as Promise<Types.EventGalleryItem>;
}
async deleteEventGalleryImage(eventId: number, imageId: number) {
return this.request<Types.MessageSchema>(`/api/events/${eventId}/gallery/${imageId}`, {
method: 'DELETE',
});
}
async deleteEvent(eventId: number) {
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
method: 'DELETE',
@@ -591,6 +1091,44 @@ class ApiClient {
);
}
async listDiscountCodes(params?: {
search?: string;
is_active?: boolean;
type?: 'percent' | 'fixed';
limit?: number;
offset?: number;
}) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.is_active != null) query.set('is_active', String(params.is_active));
if (params?.type) query.set('type', params.type);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PagedDiscountCodeSchema>(
`/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`,
);
}
async createDiscountCode(data: Types.DiscountCodeWriteSchema) {
return this.request<Types.DiscountCodeSchema>('/api/payments/admin/discount-codes', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) {
return this.request<Types.DiscountCodeSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteDiscountCode(codeId: number) {
return this.request<Types.MessageSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
method: 'DELETE',
});
}
// ============= Gallery Endpoints =============
async getGalleryImages(params?: {
@@ -614,7 +1152,7 @@ class ApiClient {
if (data.description) formData.append('description', data.description);
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
const token = localStorage.getItem('access_token');
const token = this.getStorageValue('access_token');
const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
method: 'POST',
headers: {
@@ -639,12 +1177,86 @@ class ApiClient {
});
}
async getMajors(): Promise<Types.MajorOption[]> {
return this.request('/api/meta/majors', { method: 'GET' });
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
const data = await this.getMajorsPaged({ limit: 100, offset: 0, ...params });
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
}
async getUniversities(): Promise<Types.MajorOption[]> {
return this.request('/api/meta/universities', { method: 'GET' });
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
const data = await this.getUniversitiesPaged({ limit: 100, offset: 0, ...params });
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
}
async getMajorsPaged(params?: { search?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/majors${query.toString() ? `?${query.toString()}` : ''}`);
}
async getUniversitiesPaged(params?: { search?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/universities${query.toString() ? `?${query.toString()}` : ''}`);
}
async listAdminMajors(params?: { search?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`);
}
async createMajor(data: Types.MetaOptionWriteSchema) {
return this.request<Types.MetaOptionSchema>('/api/meta/admin/majors', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateMajor(id: number, data: Types.MetaOptionWriteSchema) {
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/majors/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteMajor(id: number) {
return this.request<Types.MessageSchema>(`/api/meta/admin/majors/${id}`, {
method: 'DELETE',
});
}
async listAdminUniversities(params?: { search?: string; limit?: number; offset?: number }) {
const query = new URLSearchParams();
if (params?.search) query.set('search', params.search);
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`);
}
async createUniversity(data: Types.MetaOptionWriteSchema) {
return this.request<Types.MetaOptionSchema>('/api/meta/admin/universities', {
method: 'POST',
body: JSON.stringify(data),
});
}
async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) {
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/universities/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
async deleteUniversity(id: number) {
return this.request<Types.MessageSchema>(`/api/meta/admin/universities/${id}`, {
method: 'DELETE',
});
}
async subscribeNewsletter(email: string) {
@@ -657,6 +1269,47 @@ class ApiClient {
}
);
}
async getNotifications(params?: { limit?: number; offset?: number; type?: string }) {
const query = new URLSearchParams();
if (params?.limit != null) query.set('limit', String(params.limit));
if (params?.offset != null) query.set('offset', String(params.offset));
if (params?.type) query.set('type', params.type);
return this.request<Types.NotificationListSchema>(
`/api/notifications/${query.toString() ? `?${query.toString()}` : ''}`
);
}
async markNotificationSeen(id: string) {
return this.request<Types.NotificationSeenResponseSchema>('/api/notifications/mark-seen', {
method: 'POST',
body: JSON.stringify({ id }),
});
}
async deleteNotification(id: string) {
return this.request<Types.NotificationDeleteResponseSchema>(`/api/notifications/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
}
async markAllNotificationsRead(type?: string) {
const query = type ? `?type=${encodeURIComponent(type)}` : '';
return this.request<{ marked_read: number }>(`/api/notifications/mark-all-read${query}`, {
method: 'POST',
});
}
async issueNotificationStreamToken() {
return this.request<Types.NotificationStreamTokenResponseSchema>('/api/notifications/stream-token', {
method: 'POST',
});
}
buildNotificationStreamUrl(token: string) {
const cleanBaseUrl = this.baseUrl.replace(/\/+$/, '');
return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`;
}
}
export const api = new ApiClient(API_BASE_URL);

17
src/lib/blog-routes.ts Normal file
View File

@@ -0,0 +1,17 @@
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
export function normalizeBlogSlugParam(slug: string) {
try {
return decodeURIComponent(slug);
} catch {
return slug;
}
}
export function blogPostPath(slug: string) {
return `/blog/${encodeURIComponent(normalizeBlogSlugParam(slug))}`;
}
export function blogPostUrl(baseUrl: string, slug: string) {
return `${trimTrailingSlash(baseUrl)}${blogPostPath(slug)}`;
}

9
src/lib/helmet.tsx Normal file
View File

@@ -0,0 +1,9 @@
import * as React from "react";
export function Helmet({ children }: { children?: React.ReactNode }) {
return null;
}
export function HelmetProvider({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,52 @@
export type MarkdownHeading = {
id: string;
level: 1 | 2 | 3;
text: string;
};
function plainHeadingText(value: string) {
return value
.replace(/`([^`]+)`/g, "$1")
.replace(/\*\*([^*]+)\*\*/g, "$1")
.replace(/\*([^*]+)\*/g, "$1")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/<[^>]*>/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function headingIdBase(text: string) {
const normalized = text
.normalize("NFKC")
.toLowerCase()
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
.trim()
.replace(/\s+/g, "-")
.slice(0, 80);
return normalized || "section";
}
export function extractMarkdownHeadings(content?: string): MarkdownHeading[] {
const counters = new Map<string, number>();
return (content || "")
.split(/\r?\n/)
.map((line) => {
const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line.trim());
if (!match) return null;
const level = match[1].length as 1 | 2 | 3;
const text = plainHeadingText(match[2]);
const base = headingIdBase(text);
const nextCount = (counters.get(base) || 0) + 1;
counters.set(base, nextCount);
return {
id: nextCount === 1 ? base : `${base}-${nextCount}`,
level,
text,
};
})
.filter(Boolean) as MarkdownHeading[];
}

View File

@@ -0,0 +1,35 @@
type ProgressEvent = "start" | "done";
type Listener = (event: ProgressEvent) => void;
const listeners = new Set<Listener>();
let active = false;
function emit(event: ProgressEvent) {
listeners.forEach((listener) => listener(event));
}
export function subscribeNavigationProgress(listener: Listener) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function startNavigationProgress() {
if (active) {
return;
}
active = true;
emit("start");
}
export function completeNavigationProgress() {
if (!active) {
return;
}
active = false;
emit("done");
}

143
src/lib/public-api.ts Normal file
View File

@@ -0,0 +1,143 @@
import type * as Types from "@/lib/types";
import { apiBaseUrl } from "@/lib/site";
const DEFAULT_REVALIDATE_SECONDS = 300;
type QueryValue =
| string
| number
| boolean
| null
| undefined
| Array<string | number | boolean | null | undefined>;
export class PublicApiError extends Error {
status: number;
constructor(path: string, status: number) {
super(`Request failed for ${path}: ${status}`);
this.name = "PublicApiError";
this.status = status;
}
}
const buildUrl = (path: string, params?: Record<string, QueryValue>) => {
const url = new URL(`${apiBaseUrl}${path}`);
if (!params) {
return url.toString();
}
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((item) => {
if (item === undefined || item === null || item === "") {
return;
}
url.searchParams.append(key, String(item));
});
return;
}
if (value === undefined || value === null || value === "") {
return;
}
url.searchParams.append(key, String(value));
});
return url.toString();
};
async function requestJson<T>(
path: string,
options?: {
params?: Record<string, QueryValue>;
revalidate?: number;
cache?: RequestCache;
},
) {
const response = await fetch(
buildUrl(path, options?.params),
options?.cache
? { cache: options.cache }
: { next: { revalidate: options?.revalidate ?? DEFAULT_REVALIDATE_SECONDS } },
);
if (!response.ok) {
throw new PublicApiError(path, response.status);
}
return (await response.json()) as T;
}
export async function getPublicPosts(options?: {
search?: string;
category?: string;
tag?: string[];
author?: string[];
limit?: number;
}) {
const search = options?.search?.trim();
const category = options?.category?.trim();
const tag = options?.tag?.filter(Boolean) ?? [];
const author = options?.author?.filter(Boolean) ?? [];
return requestJson<Types.PostListSchema[]>("/api/blog/posts", {
params: {
limit: options?.limit ?? 50,
...(search ? { search } : {}),
...(category ? { category } : {}),
...(tag.length ? { tag } : {}),
...(author.length ? { author } : {}),
},
revalidate: search || category || tag.length || author.length ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getBlogFilters() {
return requestJson<Types.BlogFiltersSchema>("/api/blog/filters", {
revalidate: DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getBlogBanners() {
return requestJson<Types.BlogBannerSchema[]>("/api/blog/banners", {
revalidate: DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicPost(slug: string) {
return requestJson<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`, {
cache: "no-store",
});
}
export async function getRecommendedPosts(slug: string, limit = 3) {
return requestJson<Types.PostListSchema[]>(
`/api/blog/posts/${encodeURIComponent(slug)}/recommended`,
{
params: { limit },
revalidate: DEFAULT_REVALIDATE_SECONDS,
},
);
}
export async function getPublicEvents(options?: { search?: string; limit?: number }) {
const search = options?.search?.trim();
return requestJson<Types.EventListItemSchema[]>("/api/events/", {
params: {
status: ["published", "completed"],
limit: options?.limit ?? 50,
...(search ? { search } : {}),
},
revalidate: search ? 60 : DEFAULT_REVALIDATE_SECONDS,
});
}
export async function getPublicEventBySlug(slug: string) {
return requestJson<Types.EventDetailSchema>(
`/api/events/slug/${encodeURIComponent(slug)}`,
);
}

198
src/lib/router.tsx Normal file
View File

@@ -0,0 +1,198 @@
"use client";
import * as React from "react";
import NextLink from "next/link";
import {
useParams as useNextParams,
usePathname,
useRouter,
} from "next/navigation";
import {
completeNavigationProgress,
startNavigationProgress,
} from "@/lib/navigation-progress";
type LinkProps = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
to: string;
replace?: boolean;
prefetch?: boolean;
children: React.ReactNode;
};
type NavigateFunction = (
to: string | number,
options?: {
replace?: boolean;
},
) => void;
type NavLinkProps = Omit<LinkProps, "className"> & {
className?: string | ((state: { isActive: boolean }) => string);
};
function isPlainLeftClick(event: React.MouseEvent<HTMLAnchorElement>) {
return (
event.button === 0 &&
!event.metaKey &&
!event.ctrlKey &&
!event.shiftKey &&
!event.altKey
);
}
function shouldTrackNavigation(to: string) {
if (typeof window === "undefined") {
return true;
}
try {
const current = new URL(window.location.href);
const target = new URL(to, window.location.href);
return current.origin === target.origin && current.pathname !== target.pathname;
} catch {
return true;
}
}
export function Link({ to, replace, prefetch, children, onClick, target, ...props }: LinkProps) {
return (
<NextLink
href={to}
replace={replace}
prefetch={prefetch}
target={target}
onClick={(event) => {
onClick?.(event);
if (
event.defaultPrevented ||
target === "_blank" ||
!isPlainLeftClick(event) ||
!shouldTrackNavigation(to)
) {
return;
}
startNavigationProgress();
}}
{...props}
>
{children}
</NextLink>
);
}
export function NavLink({
to,
className,
children,
replace,
prefetch,
...props
}: NavLinkProps) {
const pathname = usePathname();
const isActive =
to === "/"
? pathname === "/"
: pathname === to || Boolean(pathname?.startsWith(`${to}/`));
const resolvedClassName =
typeof className === "function" ? className({ isActive }) : className;
return (
<Link
to={to}
replace={replace}
prefetch={prefetch}
className={resolvedClassName}
{...props}
>
{children}
</Link>
);
}
export function useNavigate(): NavigateFunction {
const router = useRouter();
return React.useCallback(
(to: string | number, options?: { replace?: boolean }) => {
if (typeof to === "number") {
startNavigationProgress();
if (to === -1) {
router.back();
return;
}
if (typeof window !== "undefined") {
window.history.go(to);
}
return;
}
if (shouldTrackNavigation(to)) {
startNavigationProgress();
} else {
completeNavigationProgress();
}
if (options?.replace) {
router.replace(to);
return;
}
router.push(to);
},
[router],
);
}
export function useParams<T extends Record<string, string | string[] | undefined>>() {
return useNextParams() as T;
}
export function useLocation() {
const pathname = usePathname();
const [search, setSearch] = React.useState("");
React.useEffect(() => {
if (typeof window === "undefined") {
setSearch("");
return;
}
setSearch(window.location.search || "");
}, [pathname]);
return React.useMemo(
() => ({
pathname: pathname ?? "",
search,
hash: "",
}),
[pathname, search],
);
}
export function useSearchParams() {
const location = useLocation();
return React.useMemo(
() => [new URLSearchParams(location.search)] as const,
[location.search],
);
}
export function Navigate({
to,
replace = false,
}: {
to: string;
replace?: boolean;
}) {
const navigate = useNavigate();
React.useEffect(() => {
navigate(to, { replace });
}, [navigate, replace, to]);
return null;
}

17
src/lib/site.ts Normal file
View File

@@ -0,0 +1,17 @@
const trimTrailingSlash = (value: string) => value.replace(/\/$/, "");
export const siteUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_SITE_URL || "http://localhost:8080",
);
export const apiBaseUrl = trimTrailingSlash(
process.env.NEXT_PUBLIC_API_BASE_URL || "http://127.0.0.1:8000",
);
export const toAbsoluteUrl = (value?: string | null, fallbackBase = siteUrl) => {
if (!value) return undefined;
if (/^https?:\/\//i.test(value)) return value;
const normalizedBase = trimTrailingSlash(fallbackBase);
const normalizedPath = value.startsWith("/") ? value : `/${value}`;
return `${normalizedBase}${normalizedPath}`;
};

View File

@@ -12,20 +12,45 @@ export interface ErrorSchema {
export interface TokenSchema {
access_token: string;
refresh_token: string;
token_type?: string;
}
export interface MajorOption {
code: string;
label: string;
id?: number;
name?: string;
user_count?: number;
}
export interface MetaOptionSchema {
id: number;
code: string;
name: string;
label: string;
user_count?: number;
}
export interface PagedMetaOptionSchema {
count: number;
results: MetaOptionSchema[];
}
export interface MetaOptionWriteSchema {
code: string;
name: string;
}
export interface UserProfileSchema {
id: number;
email: string;
email?: string | null;
mobile?: string | null;
username: string;
first_name: string;
last_name: string;
profile_picture?: string;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
bio?: string;
student_id?: string | null;
year_of_study?: number;
@@ -34,70 +59,220 @@ export interface UserProfileSchema {
date_joined: string;
is_email_verified?: boolean;
is_mobile_verified?: boolean;
requires_mobile_verification?: boolean;
has_google_link?: boolean;
is_active?: boolean;
is_staff?: boolean;
is_superuser?: boolean;
is_committee?: boolean;
is_deleted?: boolean;
deleted_at?: string | null;
can_access_blog_admin?: boolean;
can_write_blog_posts?: boolean;
can_review_blog_posts?: boolean;
}
export interface UserListSchema {
id: number;
username: string;
email: string;
email?: string | null;
mobile?: string | null;
first_name: string;
last_name: string;
full_name?: string | null;
university?: string | null;
major?: string | null;
profile_picture?: string | null;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
student_id?: string | null;
year_of_study?: number | null;
bio?: string | null;
is_email_verified?: boolean;
is_mobile_verified?: boolean;
is_deleted?: boolean;
deleted_at?: string | null;
can_access_blog_admin?: boolean;
can_write_blog_posts?: boolean;
can_review_blog_posts?: boolean;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
date_joined: string;
}
export interface UserRegistrationSchema {
email: string;
password: string;
export interface AuthorizationRoleSchema {
key: string;
label: string;
description: string;
enabled: boolean;
locked: boolean;
}
export interface UserAuthorizationSchema {
id: number;
username: string;
email?: string | null;
mobile?: string | null;
first_name: string;
last_name: string;
student_id: string;
year_of_study: number;
major: string;
university: string;
is_active: boolean;
is_staff: boolean;
is_superuser: boolean;
groups: string[];
roles: AuthorizationRoleSchema[];
}
export interface UserAuthorizationUpdateSchema {
is_staff: boolean;
groups: string[];
}
export interface UserRegistrationSchema {
mobile: string;
code: string;
password: string;
username: string;
email?: string | null;
first_name?: string | null;
last_name?: string | null;
student_id?: string | null;
year_of_study?: number | null;
major?: string | null;
university?: string | null;
}
export type UserUpdateSchema = {
email?: string | null;
first_name?: string | null;
last_name?: string | null;
bio?: string | null;
year_of_study?: number | null;
major?: string | null;
university?: string | null;
student_id?: number | null;
student_id?: string | null;
};
export interface UserLoginSchema {
email: string;
identifier: string;
password: string;
}
export interface UserOtpLoginSchema {
mobile: string;
code: string;
}
export interface RegisterOtpVerifySchema {
mobile: string;
code: string;
}
export interface OtpSendSchema {
mobile: string;
mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim";
}
export interface OtpSendResponseSchema {
message: string;
expires_in_seconds: number;
expires_at: string;
}
export interface TokenRefreshIn {
refresh_token: string;
}
export interface UsernameCheckSchema {
available: boolean;
exists: boolean;
}
export interface PasswordResetRequestSchema {
email: string;
export interface MobileLookupSchema {
exists: boolean;
has_password: boolean;
}
export interface PasswordResetConfirmSchema {
export interface PasswordResetSchema {
mobile: string;
code: string;
new_password: string;
}
export interface MobileOtpSendSchema {
mobile: string;
}
export interface MobileOtpVerifySchema {
mobile: string;
code: string;
}
export interface GoogleFlowResponseSchema {
status: "authenticated" | "collect_profile" | "claim_required" | "error";
email?: string | null;
first_name?: string | null;
last_name?: string | null;
avatar_url?: string | null;
resolution?: "new_account" | "existing_email_claim" | "existing_mobile_claim" | null;
mobile?: string | null;
mobile_hint?: string | null;
detail?: string | null;
access_token?: string | null;
refresh_token?: string | null;
}
export interface GoogleCompleteSchema {
flow: string;
mobile: string;
username?: string | null;
student_id?: string | null;
year_of_study?: number | null;
major?: string | null;
university?: string | null;
first_name?: string | null;
last_name?: string | null;
}
export interface NotificationSchema {
id: string;
type: string;
title: string;
message: string;
level: "info" | "success" | "warning" | "error";
created_at: string;
is_seen: boolean;
delete_on_seen: boolean;
action_url?: string | null;
entity_type?: string | null;
entity_id?: string | number | null;
meta?: Record<string, unknown>;
}
export interface NotificationListSchema {
count: number;
unread_count: number;
notifications: NotificationSchema[];
}
export interface NotificationSeenResponseSchema {
marked_read: boolean;
notification_id?: string | null;
deleted?: boolean;
notification?: NotificationSchema | null;
unread_count?: number | null;
}
export interface NotificationDeleteResponseSchema {
deleted: boolean;
notification_id?: string | null;
unread_count?: number | null;
}
export interface NotificationStreamTokenResponseSchema {
token: string;
password: string;
expires_in: number;
}
// Blog Types
@@ -107,19 +282,41 @@ export interface PostListSchema {
slug: string;
excerpt?: string;
featured_image?: string;
absolute_featured_image_url?: string | null;
absolute_featured_image_thumbnail_url?: string | null;
absolute_featured_image_preview_url?: string | null;
author: {
id: number;
username: string;
first_name: string;
last_name: string;
bio?: string | null;
profile_picture?: string;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
};
category?: {
id: number;
name: string;
slug: string;
description?: string;
parent_id?: number | null;
};
category_path?: Array<{
id: number;
name: string;
slug: string;
}>;
writers?: Array<{
id: number;
username: string;
first_name: string;
last_name: string;
bio?: string | null;
profile_picture?: string;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
}>;
tags: Array<{
id: number;
name: string;
@@ -130,23 +327,72 @@ export interface PostListSchema {
created_at: string;
is_featured: boolean;
reading_time?: number;
updated_at: string;
seo_title?: string;
seo_description?: string;
canonical_url?: string;
og_title?: string;
og_description?: string;
noindex?: boolean;
focus_keyword?: string;
likes_count?: number;
saves_count?: number;
comments_count?: number;
}
export interface PostDetailSchema extends PostListSchema {
content: string;
updated_at: string;
content_html?: string;
review_note?: string;
og_image_url?: string | null;
views_count?: number;
assets?: PostAssetSchema[];
}
export interface PostCreateSchema {
title: string;
content: string;
summary: string;
category_id?: number;
excerpt?: string;
category_id?: number | null;
tag_ids?: number[];
featured_image?: string;
writer_ids?: number[];
is_featured?: boolean;
status?: 'draft' | 'published';
status?: 'draft' | 'submitted' | 'changes_requested' | 'published' | 'archived';
seo_title?: string;
seo_description?: string;
canonical_url?: string;
og_title?: string;
og_description?: string;
noindex?: boolean;
focus_keyword?: string;
}
export interface PostReviewSchema {
action: 'publish' | 'approve' | 'request_changes' | 'changes_requested' | 'archive';
note?: string;
}
export interface PostAssetSchema {
id: number;
file_type: 'image' | 'video' | 'document' | 'archive' | 'other';
title: string;
alt_text?: string;
caption?: string;
size: number;
mime_type?: string;
created_at: string;
absolute_file_url?: string | null;
absolute_thumbnail_url?: string | null;
absolute_preview_url?: string | null;
absolute_blur_url?: string | null;
markdown_image?: string | null;
markdown_link?: string | null;
uploaded_by: {
id: number;
username: string;
first_name: string;
last_name: string;
};
}
export interface CommentSchema {
@@ -157,13 +403,24 @@ export interface CommentSchema {
username: string;
first_name: string;
last_name: string;
bio?: string | null;
profile_picture?: string | null;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
};
post_id: number;
post_title: string;
post_slug: string;
parent_id?: number;
created_at: string;
updated_at?: string;
is_approved: boolean;
is_hidden?: boolean;
is_deleted?: boolean;
hidden_at?: string | null;
deleted_at?: string | null;
hidden_replies_count?: number;
replies?: CommentSchema[];
}
export interface CommentCreateSchema {
@@ -171,14 +428,54 @@ export interface CommentCreateSchema {
parent_id?: number;
}
export interface CommentUpdateSchema {
content: string;
}
export interface BlogBannerSchema {
id: number;
title?: string;
alt_text?: string;
image_url: string;
url: string;
sort_order: number;
}
export interface BlogInteractionSchema {
liked: boolean;
saved: boolean;
likes_count: number;
saves_count: number;
comments_count: number;
}
export interface BlogProfileActivitySchema {
liked_posts: PostListSchema[];
saved_posts: PostListSchema[];
comments: CommentSchema[];
replies: CommentSchema[];
}
export interface CategorySchema {
id: number;
name: string;
slug: string;
description?: string;
parent_id?: number | null;
created_at: string;
}
export interface AdminCategorySchema extends CategorySchema {
post_count: number;
}
export interface CategoryWriteSchema {
name: string;
slug?: string | null;
description?: string | null;
parent_id?: number | null;
}
export interface TagSchema {
id: number;
name: string;
@@ -186,6 +483,45 @@ export interface TagSchema {
created_at: string;
}
export interface AdminTagSchema extends TagSchema {
post_count: number;
}
export interface TagWriteSchema {
name: string;
slug?: string | null;
}
export interface BlogFilterCategory {
id: number;
name: string;
slug: string;
parent_id?: number | null;
post_count: number;
children: BlogFilterCategory[];
}
export interface BlogFilterTag {
id: number;
name: string;
slug: string;
post_count: number;
}
export interface BlogFilterAuthor {
id: number;
username: string;
first_name: string;
last_name: string;
post_count: number;
}
export interface BlogFiltersSchema {
categories: BlogFilterCategory[];
tags: BlogFilterTag[];
authors: BlogFilterAuthor[];
}
// Events Types
export interface EventListItemSchema {
id: number;
@@ -194,6 +530,8 @@ export interface EventListItemSchema {
description: string;
featured_image?: string | null;
absolute_featured_image_url?: string | null;
absolute_featured_image_thumbnail_url?: string | null;
absolute_featured_image_preview_url?: string | null;
event_type: 'online' | 'on_site' | 'hybrid';
address?: string | null;
location?: string | null;
@@ -205,6 +543,8 @@ export interface EventListItemSchema {
capacity?: number | null;
price?: number | null;
status: 'draft' | 'published' | 'cancelled' | 'completed';
status_label?: string;
event_type_label?: string;
registration_count: number;
created_at: string;
}
@@ -214,8 +554,16 @@ export interface EventGalleryItem {
title: string;
description: string;
absolute_image_url?: string | null;
absolute_image_preview_url?: string | null;
absolute_image_blur_url?: string | null;
width?: number;
height?: number;
file_size_mb?: number;
markdown_url?: string;
image?: string;
alt_text?: string | null;
is_public?: boolean;
created_at?: string;
}
export interface EventDetailSchema extends EventListItemSchema {
@@ -228,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema {
export interface EventCreateSchema {
title: string;
description: string;
start_date: string;
end_date?: string;
location: string;
capacity?: number;
event_image?: string;
requirements?: string;
is_registration_open?: boolean;
slug?: string | null;
event_type: 'online' | 'on_site' | 'hybrid';
address?: string | null;
location?: string | null;
online_link?: string | null;
start_time: string;
end_time: string;
registration_start_date?: string | null;
registration_end_date?: string | null;
capacity?: number | null;
price?: number | null;
status?: 'draft' | 'published' | 'cancelled' | 'completed';
registration_success_markdown?: string | null;
gallery_image_ids?: number[] | null;
}
export interface PaymentAdminSchema {
id: number;
authority?: string | null;
ref_id?: string | null;
card_pan?: string | null;
card_hash?: string | null;
status: number;
status_label: string;
base_amount: number;
@@ -265,6 +622,14 @@ export interface RegistrationAdminSchema {
first_name: string;
last_name: string;
email: string;
mobile?: string | null;
profile_picture?: string | null;
profile_picture_thumbnail_url?: string | null;
profile_picture_preview_url?: string | null;
university?: string | null;
major?: string | null;
student_id?: string | null;
year_of_study?: number | null;
};
payments: PaymentAdminSchema[];
}
@@ -274,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
}
export interface EventUpdateSchema {
title?: string;
slug?: string | null;
description?: string;
event_type?: 'online' | 'on_site' | 'hybrid';
address?: string | null;
@@ -286,9 +652,47 @@ export interface EventUpdateSchema {
capacity?: number | null;
price?: number | null;
status?: 'draft' | 'published' | 'cancelled' | 'completed';
registration_success_markdown?: string | null;
gallery_image_ids?: number[] | null;
}
export interface DiscountCodeSchema {
id: number;
code: string;
type: 'percent' | 'fixed';
value: number;
max_discount?: number | null;
is_active: boolean;
starts_at?: string | null;
ends_at?: string | null;
usage_limit_total?: number | null;
usage_limit_per_user?: number | null;
min_amount?: number | null;
applicable_event_ids: number[];
usage_count: number;
created_at: string;
updated_at: string;
}
export interface PagedDiscountCodeSchema {
count: number;
results: DiscountCodeSchema[];
}
export interface DiscountCodeWriteSchema {
code: string;
type: 'percent' | 'fixed';
value: number;
max_discount?: number | null;
is_active?: boolean;
starts_at?: string | null;
ends_at?: string | null;
usage_limit_total?: number | null;
usage_limit_per_user?: number | null;
min_amount?: number | null;
applicable_event_ids?: number[];
}
export interface EventRegistrationSchema {
id: number;
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
@@ -322,6 +726,11 @@ export interface GalleryImageSchema {
title: string;
description?: string;
image: string;
absolute_image_url?: string | null;
absolute_image_preview_url?: string | null;
absolute_image_blur_url?: string | null;
width?: number | null;
height?: number | null;
uploaded_by: {
id: number;
username: string;
@@ -344,6 +753,197 @@ export interface PaginatedResponse<T> {
previous?: string;
}
// Admin analytics
export interface AnalyticsPointSchema {
label: string;
value: number;
}
export interface AnalyticsPointGroupSchema {
items: AnalyticsPointSchema[];
top_items: AnalyticsPointSchema[];
other_count: number;
total_count: number;
}
export interface AnalyticsTrendPointSchema {
date: string;
label: string;
value: number;
}
export interface AnalyticsRegistrationStatusSchema {
status: string;
label: string;
value: number;
}
export interface AnalyticsTopEventSchema {
id: number;
title: string;
slug: string;
attendees: number;
capacity?: number | null;
fill_rate?: number | null;
revenue: number;
}
export interface AnalyticsPostPopularitySchema {
id: number;
title: string;
slug: string;
likes: number;
saves: number;
comments: number;
}
export interface AnalyticsPostPopularityGroupSchema {
items: AnalyticsPostPopularitySchema[];
top_items: AnalyticsPostPopularitySchema[];
other_count: number;
total_count: number;
}
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
score: number;
}
export interface AdminDashboardAnalyticsSchema {
filters: {
date_from?: string | null;
date_to?: string | null;
event_id?: number | null;
granularity: 'day' | 'week' | 'month';
};
summary: {
total_users: number;
verified_users: number;
total_events: number;
total_registrations: number;
total_revenue: number;
total_discount: number;
published_posts: number;
total_likes: number;
total_saves: number;
total_comments: number;
};
users: {
signup_trend: AnalyticsTrendPointSchema[];
by_major: AnalyticsPointSchema[];
by_university: AnalyticsPointSchema[];
by_year: AnalyticsPointSchema[];
};
events: {
registration_status: AnalyticsRegistrationStatusSchema[];
by_major: AnalyticsPointSchema[];
by_university: AnalyticsPointSchema[];
top_events: AnalyticsTopEventSchema[];
registration_trend: AnalyticsTrendPointSchema[];
};
revenue: {
trend: AnalyticsTrendPointSchema[];
by_event: AnalyticsPointSchema[];
payment_status: AnalyticsRegistrationStatusSchema[];
total_paid: number;
total_discount: number;
total_base: number;
};
blog: {
totals: {
posts: number;
likes: number;
saves: number;
comments: number;
};
post_popularity: AnalyticsPostPopularitySchema[];
top_posts: AnalyticsTopPostSchema[];
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
by_category: AnalyticsPointSchema[];
by_tag: AnalyticsPointSchema[];
};
achievements: {
distinct_participants: number;
learning_hours: number;
published_content: number;
community_engagement: number;
};
}
export interface AnalyticsEventOptionsSchema {
count: number;
results: Array<{
value: string;
label: string;
description?: string | null;
}>;
}
export interface UserAnalyticsSchema {
filters: {
date_from?: string | null;
date_to?: string | null;
granularity: 'day' | 'week' | 'month';
};
summary: {
total_users: number;
verified_users: number;
unverified_users: number;
profile_completion_rate: number;
};
signup_trend: AnalyticsTrendPointSchema[];
by_major: AnalyticsPointGroupSchema;
by_university: AnalyticsPointGroupSchema;
by_year: AnalyticsPointGroupSchema;
}
export interface EventAnalyticsSchema {
filters: {
date_from?: string | null;
date_to?: string | null;
event_id?: number | null;
};
summary: {
total_events: number;
total_registrations: number;
distinct_participants: number;
total_revenue: number;
total_discount: number;
total_base: number;
learning_hours: number;
};
registration_status: AnalyticsRegistrationStatusSchema[];
payment_status: AnalyticsRegistrationStatusSchema[];
attendee_by_major: AnalyticsPointGroupSchema;
attendee_by_university: AnalyticsPointGroupSchema;
registration_trend: AnalyticsTrendPointSchema[];
revenue_trend: AnalyticsTrendPointSchema[];
revenue_by_event: AnalyticsPointGroupSchema;
top_events: {
top_items: AnalyticsTopEventSchema[];
other_count: number;
total_count: number;
};
}
export interface BlogAnalyticsSchema {
filters: {
date_from?: string | null;
date_to?: string | null;
};
summary: {
published_posts: number;
total_likes: number;
total_saves: number;
total_comments: number;
community_engagement: number;
};
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
post_popularity: AnalyticsPostPopularityGroupSchema;
top_posts: AnalyticsTopPostSchema[];
by_category: AnalyticsPointGroupSchema;
by_tag: AnalyticsPointGroupSchema;
}
// payment
export interface CreatePaymentOut {
start_pay_url: string;

View File

@@ -35,11 +35,105 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
}
}
const DEFAULT_THUMB = '/images/event-placeholder.svg';
export const getThumbUrl = (e: Types.EventListItemSchema) =>
e.absolute_featured_image_url ||
e.featured_image ||
DEFAULT_THUMB;
export function formatJalaliDate(iso?: string): string {
if (!iso) return '—';
try {
const date = new Date(iso);
const locale = Intl.DateTimeFormat.supportedLocalesOf(['fa-IR-u-ca-persian']).length > 0
? 'fa-IR-u-ca-persian'
: 'fa-IR';
return new Intl.DateTimeFormat(locale, {
day: 'numeric',
month: 'long',
year: 'numeric',
}).format(date);
} catch {
return '—';
}
}
const DEFAULT_THUMB = "/placeholder.svg";
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
values.find((value) => Boolean(value)) || DEFAULT_THUMB;
export const getEventCardImageUrl = (event: Types.EventListItemSchema) =>
pickFirstUrl(
event.absolute_featured_image_thumbnail_url,
event.absolute_featured_image_preview_url,
event.absolute_featured_image_url,
event.featured_image,
);
export const getEventHeroImageUrl = (event: Types.EventListItemSchema) =>
pickFirstUrl(
event.absolute_featured_image_preview_url,
event.absolute_featured_image_url,
event.absolute_featured_image_thumbnail_url,
event.featured_image,
);
export const getEventSeoImageUrl = (event: Types.EventListItemSchema) =>
pickFirstUrl(
event.absolute_featured_image_url,
event.absolute_featured_image_preview_url,
event.absolute_featured_image_thumbnail_url,
event.featured_image,
);
export const getThumbUrl = getEventCardImageUrl;
export const getBlogCardImageUrl = (post: Types.PostListSchema) =>
[
post.absolute_featured_image_thumbnail_url,
post.absolute_featured_image_preview_url,
post.absolute_featured_image_url,
post.featured_image,
].find((value) => Boolean(value));
export const getBlogHeroImageUrl = (post: Types.PostListSchema) =>
[
post.absolute_featured_image_preview_url,
post.absolute_featured_image_url,
post.absolute_featured_image_thumbnail_url,
post.featured_image,
].find((value) => Boolean(value));
export const getGalleryImagePreviewUrl = (
image:
| Types.EventGalleryItem
| Types.GalleryImageSchema,
) =>
pickFirstUrl(
image.absolute_image_preview_url,
image.absolute_image_url,
"image" in image ? image.image : undefined,
);
export const getGalleryImageBlurUrl = (
image:
| Types.EventGalleryItem
| Types.GalleryImageSchema,
) =>
pickFirstUrl(
image.absolute_image_blur_url,
image.absolute_image_preview_url,
image.absolute_image_url,
"image" in image ? image.image : undefined,
);
export const getGalleryImageFullUrl = (
image:
| Types.EventGalleryItem
| Types.GalleryImageSchema,
) =>
pickFirstUrl(
image.absolute_image_url,
image.absolute_image_preview_url,
image.absolute_image_blur_url,
"image" in image ? image.image : undefined,
);
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];

Some files were not shown because too many files have changed in this diff Show More