Compare commits
61 Commits
42f2087b7c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b64c6cf612 | |||
| 958400a8c1 | |||
| da8d82955e | |||
| 021bee9444 | |||
| ecd4a57da9 | |||
| f30d53df7e | |||
| 4edf8a0736 | |||
| 4fb44fcb4c | |||
| 268dd26d9a | |||
| 6ba8f6ec8b | |||
| e3ddb733ee | |||
| 9f07c0740d | |||
| 83321c1d39 | |||
| 6c3a7ed5f4 | |||
| 5c15727516 | |||
| fc94ceb9f5 | |||
| 4e24b96068 | |||
| a76a8a96ff | |||
| ec38a4435e | |||
| 9d05973a19 | |||
| 4e800a8bd9 | |||
| 0e7bf49b61 | |||
| 9080b0caea | |||
| 25bd46ea2a | |||
| 9e5be244f0 | |||
| 10992303de | |||
| 053b742f89 | |||
| 489e46dd06 | |||
| 25cbf53179 | |||
| cb8eeadba9 | |||
| eb28a00abd | |||
| 1e302d2aa2 | |||
| 4611c8d63b | |||
| 971b709169 | |||
| 492bfd9918 | |||
| 5fcc370611 | |||
| 9051e32e5a | |||
| bced5dceb1 | |||
| e06a4b1cf8 | |||
| b953d78b19 | |||
| 5a9d36efa9 | |||
| ed14ea9488 | |||
| 0668fa2bb3 | |||
| da95b4ec99 | |||
| 53d989f730 | |||
| f424225abc | |||
| e89fcfb20e | |||
| 3ec931aabb | |||
| 7ddc6b158d | |||
| 0d01933f9d | |||
| 039158e0c4 | |||
| 8e5096d192 | |||
| 8b1fc942cf | |||
| 37b123838f | |||
| 49dcb1dd1b | |||
| f2b4cfce1a | |||
| 66bb2fa107 | |||
| 18de81c173 | |||
| 5711961b9b | |||
| f2d5b92b22 | |||
| f23108cda3 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.next
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
dist
|
||||||
@@ -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
1
.gitignore
vendored
@@ -8,6 +8,7 @@ pnpm-debug.log*
|
|||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
.next
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
.env
|
.env
|
||||||
|
|||||||
46
README.md
46
README.md
@@ -1,43 +1,41 @@
|
|||||||
# Frontend
|
# Frontend
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Vite + React 18 with TypeScript.
|
- Next.js App Router with React 18 and TypeScript.
|
||||||
- `@tanstack/react-query` for data fetching and caching.
|
- `@tanstack/react-query` for client-side authenticated flows.
|
||||||
- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS.
|
- Tailwind CSS and shadcn/ui components.
|
||||||
- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers.
|
- `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
|
## Development
|
||||||
|
|
||||||
### Install dependencies
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure API base URL
|
The local dev server runs on `http://localhost:8080`.
|
||||||
```bash
|
|
||||||
cp .env.sample .env
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run dev server
|
## Production build
|
||||||
```bash
|
|
||||||
npm run dev -- --host
|
|
||||||
```
|
|
||||||
|
|
||||||
### Production build
|
|
||||||
```bash
|
```bash
|
||||||
npm run build
|
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
|
## Routes
|
||||||
- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments.
|
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
|
||||||
- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion.
|
- Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
|
||||||
- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog).
|
- Admin: `/admin/*`
|
||||||
|
|
||||||
## Testing & linting
|
## Validation
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy.
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import js from "@eslint/js";
|
import js from "@eslint/js";
|
||||||
|
import nextPlugin from "@next/eslint-plugin-next";
|
||||||
import globals from "globals";
|
import globals from "globals";
|
||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist", ".next", "next-env.d.ts"] },
|
||||||
{
|
{
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
files: ["**/*.{ts,tsx}"],
|
files: ["**/*.{ts,tsx}"],
|
||||||
@@ -14,12 +14,12 @@ export default tseslint.config(
|
|||||||
globals: globals.browser,
|
globals: globals.browser,
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
"@next/next": nextPlugin,
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
|
...nextPlugin.configs.recommended.rules,
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
|
||||||
"@typescript-eslint/no-unused-vars": "off",
|
"@typescript-eslint/no-unused-vars": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
22
index.html
22
index.html
@@ -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
6
next-env.d.ts
vendored
Normal 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
25
next.config.ts
Normal 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;
|
||||||
33
nginx.conf
33
nginx.conf
@@ -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
2762
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
37
package.json
37
package.json
@@ -1,17 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "vite_react_shadcn_ts",
|
"name": "guilan-ace-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "next dev --port 8080",
|
||||||
"build": "vite build",
|
"build": "next build",
|
||||||
"build:dev": "vite build --mode development",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"start": "next start --port 3000"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@radix-ui/react-accordion": "^1.2.11",
|
"@radix-ui/react-accordion": "^1.2.11",
|
||||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||||
@@ -45,17 +51,22 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
|
"jspdf": "^2.5.1",
|
||||||
"lucide-react": "^0.462.0",
|
"lucide-react": "^0.462.0",
|
||||||
|
"next": "^15.4.6",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-date-object": "^2.1.9",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-helmet-async": "^2.0.5",
|
|
||||||
"react-hook-form": "^7.61.1",
|
"react-hook-form": "^7.61.1",
|
||||||
"react-markdown": "^9.0.3",
|
"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-resizable-panels": "^2.1.9",
|
||||||
"react-router-dom": "^6.30.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
@@ -64,28 +75,24 @@
|
|||||||
"tailwind-merge": "^2.6.0",
|
"tailwind-merge": "^2.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"vaul": "^0.9.9",
|
"vaul": "^0.9.9",
|
||||||
"zod": "^3.25.76",
|
"zod": "^3.25.76"
|
||||||
"react-qr-code": "^2.0.11",
|
|
||||||
"jspdf": "^2.5.1",
|
|
||||||
"html2canvas": "^1.4.1"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.32.0",
|
"@eslint/js": "^9.32.0",
|
||||||
|
"@next/eslint-plugin-next": "^16.2.6",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@types/node": "^22.16.5",
|
"@types/node": "^22.16.5",
|
||||||
"@types/react": "^18.3.26",
|
"@types/react": "^18.3.26",
|
||||||
"@types/react-dom": "^18.3.7",
|
"@types/react-dom": "^18.3.7",
|
||||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"autoprefixer": "^10.4.21",
|
"autoprefixer": "^10.4.21",
|
||||||
"eslint": "^9.32.0",
|
"eslint": "^9.32.0",
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
"eslint-plugin-react-refresh": "^0.4.20",
|
||||||
"globals": "^15.15.0",
|
"globals": "^15.15.0",
|
||||||
"lovable-tagger": "^1.1.10",
|
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.38.0",
|
"typescript-eslint": "^8.38.0"
|
||||||
"vite": "^5.4.19"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
User-agent: Googlebot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Bingbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: Twitterbot
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: facebookexternalhit
|
|
||||||
Allow: /
|
|
||||||
|
|
||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
72
src/App.tsx
72
src/App.tsx
@@ -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
65
src/app/about/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/admin/authorizations/page.tsx
Normal file
5
src/app/admin/authorizations/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminAuthorizations from "@/views/AdminAuthorizations";
|
||||||
|
|
||||||
|
export default function AdminAuthorizationsPage() {
|
||||||
|
return <AdminAuthorizations />;
|
||||||
|
}
|
||||||
8
src/app/admin/blog/[id]/assets/page.tsx
Normal file
8
src/app/admin/blog/[id]/assets/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
8
src/app/admin/blog/[id]/edit/page.tsx
Normal file
8
src/app/admin/blog/[id]/edit/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
8
src/app/admin/blog/[id]/preview/page.tsx
Normal file
8
src/app/admin/blog/[id]/preview/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/categories/page.tsx
Normal file
5
src/app/admin/blog/categories/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlogCategories from "@/views/AdminBlogCategories";
|
||||||
|
|
||||||
|
export default function AdminBlogCategoriesPage() {
|
||||||
|
return <AdminBlogCategories />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/page.tsx
Normal file
5
src/app/admin/blog/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlog from "@/views/AdminBlog";
|
||||||
|
|
||||||
|
export default function AdminBlogPage() {
|
||||||
|
return <AdminBlog />;
|
||||||
|
}
|
||||||
5
src/app/admin/blog/tags/page.tsx
Normal file
5
src/app/admin/blog/tags/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminBlogTags from "@/views/AdminBlogTags";
|
||||||
|
|
||||||
|
export default function AdminBlogTagsPage() {
|
||||||
|
return <AdminBlogTags />;
|
||||||
|
}
|
||||||
5
src/app/admin/coupons/page.tsx
Normal file
5
src/app/admin/coupons/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminCoupons from "@/views/AdminCoupons";
|
||||||
|
|
||||||
|
export default function AdminCouponsRoute() {
|
||||||
|
return <AdminCoupons />;
|
||||||
|
}
|
||||||
5
src/app/admin/dashboard/page.tsx
Normal file
5
src/app/admin/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminDashboard from "@/views/AdminDashboard";
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
return <AdminDashboard />;
|
||||||
|
}
|
||||||
11
src/app/admin/events/[id]/edit/page.tsx
Normal file
11
src/app/admin/events/[id]/edit/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
11
src/app/admin/events/[id]/page.tsx
Normal file
11
src/app/admin/events/[id]/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
5
src/app/admin/events/create/page.tsx
Normal file
5
src/app/admin/events/create/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminEventForm from "@/views/AdminEventForm";
|
||||||
|
|
||||||
|
export default function AdminEventCreateRoute() {
|
||||||
|
return <AdminEventForm mode="create" />;
|
||||||
|
}
|
||||||
11
src/app/admin/events/page.tsx
Normal file
11
src/app/admin/events/page.tsx
Normal 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
10
src/app/admin/layout.tsx
Normal 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>;
|
||||||
|
}
|
||||||
5
src/app/admin/majors/page.tsx
Normal file
5
src/app/admin/majors/page.tsx
Normal 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
5
src/app/admin/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
5
src/app/admin/universities/page.tsx
Normal file
5
src/app/admin/universities/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import AdminMetaOptions from "@/views/AdminMetaOptions";
|
||||||
|
|
||||||
|
export default function AdminUniversitiesRoute() {
|
||||||
|
return <AdminMetaOptions kind="universities" />;
|
||||||
|
}
|
||||||
11
src/app/admin/users/page.tsx
Normal file
11
src/app/admin/users/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
11
src/app/auth/google/callback/page.tsx
Normal file
11
src/app/auth/google/callback/page.tsx
Normal 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
11
src/app/auth/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
5
src/app/blog/[slug]/loading.tsx
Normal file
5
src/app/blog/[slug]/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { BlogDetailPageLoading } from "@/components/page-loading";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <BlogDetailPageLoading />;
|
||||||
|
}
|
||||||
329
src/app/blog/[slug]/page.tsx
Normal file
329
src/app/blog/[slug]/page.tsx
Normal 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
5
src/app/blog/loading.tsx
Normal 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
73
src/app/blog/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/events/[slug]/loading.tsx
Normal file
5
src/app/events/[slug]/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { DetailPageLoading } from "@/components/page-loading";
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <DetailPageLoading />;
|
||||||
|
}
|
||||||
115
src/app/events/[slug]/page.tsx
Normal file
115
src/app/events/[slug]/page.tsx
Normal 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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/app/events/[slug]/success/page.tsx
Normal file
11
src/app/events/[slug]/success/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
5
src/app/events/loading.tsx
Normal file
5
src/app/events/loading.tsx
Normal 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
51
src/app/events/page.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
BIN
src/app/fonts/Vazirmatn-variable.woff2
Normal file
BIN
src/app/fonts/Vazirmatn-variable.woff2
Normal file
Binary file not shown.
52
src/app/layout.tsx
Normal file
52
src/app/layout.tsx
Normal 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
11
src/app/logout/page.tsx
Normal 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
19
src/app/not-found.tsx
Normal 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
56
src/app/page.tsx
Normal 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 />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
src/app/payments/result/page.tsx
Normal file
16
src/app/payments/result/page.tsx
Normal 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
11
src/app/profile/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
11
src/app/reset-password/[token]/page.tsx
Normal file
11
src/app/reset-password/[token]/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
11
src/app/reset-password/page.tsx
Normal file
11
src/app/reset-password/page.tsx
Normal 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
23
src/app/robots.ts
Normal 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
55
src/app/sitemap.ts
Normal 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;
|
||||||
|
}
|
||||||
11
src/app/verify-email/[token]/page.tsx
Normal file
11
src/app/verify-email/[token]/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
96
src/components/AdminDateTimeField.tsx
Normal file
96
src/components/AdminDateTimeField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
src/components/AsyncSearchableCombobox.tsx
Normal file
178
src/components/AsyncSearchableCombobox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/components/BlogPostActions.tsx
Normal file
117
src/components/BlogPostActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
507
src/components/BlogPostInteractions.tsx
Normal file
507
src/components/BlogPostInteractions.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
src/components/BlogTableOfContents.tsx
Normal file
103
src/components/BlogTableOfContents.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/BlogThumbnail.tsx
Normal file
58
src/components/BlogThumbnail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
src/components/ConfirmAction.tsx
Normal file
58
src/components/ConfirmAction.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,75 +1,55 @@
|
|||||||
import * as React from "react";
|
"use client";
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
|
import { Link } from "@/lib/router";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
|
||||||
import { Instagram, Send, Twitter, Linkedin } from "lucide-react";
|
|
||||||
import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح دادهام
|
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
// const { toast } = useToast();
|
|
||||||
// const [email, setEmail] = React.useState("");
|
|
||||||
// const [loading, setLoading] = React.useState(false);
|
|
||||||
const year = new Date().getFullYear();
|
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 (
|
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="container mx-auto px-4 py-10">
|
||||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
|
||||||
{/* برند + درباره + اینماد */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
|
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
|
||||||
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
|
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground leading-7">
|
<p className="text-sm text-muted-foreground leading-7">
|
||||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهٔ دانشجویی و پیوند با صنعت.
|
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* لینکهای سریع */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
|
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
|
||||||
<ul className="space-y-2 text-sm">
|
<ul className="space-y-2 text-sm">
|
||||||
<li><Link to="/" className="text-muted-foreground hover:text-foreground">خانه</Link></li>
|
<li>
|
||||||
<li><Link to="/events" className="text-muted-foreground hover:text-foreground">رویدادها</Link></li>
|
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||||
<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>
|
</Link>
|
||||||
{/* <li><Link to="/contact" className="text-muted-foreground hover:text-foreground">تماس با ما</Link></li> */}
|
</li>
|
||||||
{/* <li><Link to="/rules" 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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* اطلاعات تماس / شبکههای اجتماعی */}
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
|
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
|
||||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||||
@@ -78,53 +58,49 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<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="اینستاگرام">
|
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
|
||||||
<Instagram className="h-4 w-4" />
|
<Instagram className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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="تلگرام">
|
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
|
||||||
<Send className="h-4 w-4" />
|
<Send className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</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="لینکدین">
|
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
|
||||||
<Linkedin className="h-4 w-4" />
|
<Linkedin className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
|
<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" />
|
<Twitter className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="justify-self-end">
|
||||||
<a
|
<a
|
||||||
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
||||||
@@ -134,7 +110,7 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="/enamad.png"
|
src="/enamad.png"
|
||||||
width="125px"
|
width="125"
|
||||||
alt="نماد اعتماد الکترونیکی"
|
alt="نماد اعتماد الکترونیکی"
|
||||||
referrerPolicy="origin"
|
referrerPolicy="origin"
|
||||||
style={{ cursor: "pointer" }}
|
style={{ cursor: "pointer" }}
|
||||||
@@ -144,12 +120,10 @@ export default function Footer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* خط جداکننده */}
|
|
||||||
<div className="my-8 h-px w-full bg-border" />
|
<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 className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
|
||||||
<div>© {year} انجمن علمی کامپیوتر گیلان — تمامی حقوق محفوظ است.</div>
|
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
175
src/components/GalleryLightbox.tsx
Normal file
175
src/components/GalleryLightbox.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,101 +1,164 @@
|
|||||||
import React from 'react';
|
"use client";
|
||||||
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';
|
|
||||||
|
|
||||||
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 = {
|
type MarkdownProps = {
|
||||||
content?: string;
|
content?: string;
|
||||||
allowHtml?: boolean;
|
allowHtml?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
dir?: 'rtl' | 'ltr';
|
dir?: "rtl" | "ltr";
|
||||||
justify?: boolean;
|
justify?: boolean;
|
||||||
size?: MarkdownSize;
|
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({
|
export default function Markdown({
|
||||||
content = '',
|
content = "",
|
||||||
allowHtml = false,
|
allowHtml = false,
|
||||||
className = '',
|
className = "",
|
||||||
dir = 'rtl',
|
dir = "rtl",
|
||||||
justify = false,
|
justify = false,
|
||||||
size = 'sm',
|
size = "sm",
|
||||||
}: MarkdownProps) {
|
}: MarkdownProps) {
|
||||||
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
|
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
|
||||||
|
const headings = extractMarkdownHeadings(content);
|
||||||
|
let headingIndex = 0;
|
||||||
|
|
||||||
const baseSizeClass =
|
const baseSizeClass =
|
||||||
size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base';
|
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
|
||||||
|
|
||||||
const hScale =
|
const hScale =
|
||||||
size === 'sm'
|
size === "sm"
|
||||||
? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' }
|
? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" }
|
||||||
: size === 'base'
|
: size === "base"
|
||||||
? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' }
|
? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" }
|
||||||
: { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' };
|
: { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" };
|
||||||
|
|
||||||
const justifyStyle: React.CSSProperties | undefined = justify
|
const justifyStyle: React.CSSProperties | undefined = justify
|
||||||
? { textAlign: 'justify', textJustify: 'inter-word' }
|
? { textAlign: "justify", textJustify: "inter-word" }
|
||||||
: undefined;
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
dir={dir}
|
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}
|
style={justifyStyle}
|
||||||
>
|
>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={rehypePlugins}
|
||||||
components={{
|
components={{
|
||||||
h1: (p) => <h1 className={`mt-6 font-bold ${hScale.h1}`} {...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 className={`mt-6 font-bold ${hScale.h2}`} {...p} />,
|
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 className={`mt-5 font-semibold ${hScale.h3}`} {...p} />,
|
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 className={`mt-4 font-semibold ${hScale.h4}`} {...p} />,
|
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
|
||||||
p: (p) => <p className="my-3" {...p} />,
|
p: (p) => <p className="my-4" {...p} />,
|
||||||
a: (p) => <a className="underline decoration-primary hover:opacity-90 break-all" target="_blank" rel="noopener noreferrer" {...p} />,
|
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,
|
||||||
ul: (p) => <ul className="my-3 list-disc ps-6 space-y-1.5" {...p} />,
|
ul: (p) => <ul className="my-4 list-disc space-y-1.5 pe-0 ps-6" {...p} />,
|
||||||
ol: (p) => <ol className="my-3 list-decimal ps-6 space-y-1.5" {...p} />,
|
ol: (p) => <ol className="my-4 list-decimal space-y-1.5 pe-0 ps-6" {...p} />,
|
||||||
li: (p) => <li className="[&>ul]:my-1.5 [&>ol]:my-1.5" {...p} />,
|
li: (p) => <li className="[&>ol]:my-1.5 [&>ul]:my-1.5" {...p} />,
|
||||||
hr: (p) => <hr className="my-5 border-muted" {...p} />,
|
hr: (p) => <hr className="my-6 border-muted" {...p} />,
|
||||||
blockquote: (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 }) => {
|
code: ({ className, children, node, ...p }) => {
|
||||||
const isInline =
|
const isInline =
|
||||||
node?.tagName === 'code' &&
|
node?.tagName === "code" &&
|
||||||
!/language-/.test(className || '') &&
|
!/language-/.test(className || "") &&
|
||||||
!String(children).includes('\n');
|
!String(children).includes("\n");
|
||||||
|
|
||||||
if (isInline) {
|
if (isInline) {
|
||||||
return (
|
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}
|
{children}
|
||||||
</code>
|
</code>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||||
<code className={className} {...p}>
|
|
||||||
{children}
|
|
||||||
</code>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
pre: ({ className = '', children, ...p }) => (
|
pre: ({ children }) => <>{children}</>,
|
||||||
<pre
|
|
||||||
className={[
|
|
||||||
"my-4 overflow-x-auto rounded-md bg-muted p-4 text-[0.9em]",
|
|
||||||
className,
|
|
||||||
].filter(Boolean).join(" ")}
|
|
||||||
{...p}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</pre>
|
|
||||||
),
|
|
||||||
table: (p) => (
|
table: (p) => (
|
||||||
<div className="my-3 overflow-x-auto">
|
<div className="my-4 overflow-x-auto">
|
||||||
<table className="w-full border-collapse" {...p} />
|
<table className="w-full border-collapse" {...p} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
683
src/components/MarkdownEditor.tsx
Normal file
683
src/components/MarkdownEditor.tsx
Normal 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: `` },
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
99
src/components/MobileBottomNav.tsx
Normal file
99
src/components/MobileBottomNav.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
222
src/components/MobileVerificationGate.tsx
Normal file
222
src/components/MobileVerificationGate.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +1,37 @@
|
|||||||
// src/components/ModeToggle.tsx
|
import { Button } from "@/components/ui/button";
|
||||||
import { Button } from '@/components/ui/button';
|
import { useTheme } from "@/components/ThemeProvider";
|
||||||
import { Moon, Sun } from 'lucide-react';
|
import { cn } from "@/lib/utils";
|
||||||
import { useTheme } from '@/components/ThemeProvider';
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
|
||||||
export default function ModeToggle() {
|
export default function ModeToggle({ className }: { className?: string }) {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
if (theme === 'system' && typeof window !== 'undefined') {
|
if (theme === "system" && typeof window !== "undefined") {
|
||||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||||
setTheme(prefersDark ? 'light' : 'dark');
|
setTheme(prefersDark ? "light" : "dark");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
setTheme(theme === "dark" ? "light" : "dark");
|
||||||
};
|
};
|
||||||
|
|
||||||
const isDark =
|
const isDark =
|
||||||
theme === 'dark' ||
|
theme === "dark" ||
|
||||||
(theme === 'system' &&
|
(theme === "system" &&
|
||||||
typeof document !== 'undefined' &&
|
typeof document !== "undefined" &&
|
||||||
document.documentElement.classList.contains('dark'));
|
document.documentElement.classList.contains("dark"));
|
||||||
|
|
||||||
const nextThemeLabel = isDark ? 'روشن' : 'تاریک';
|
const nextThemeLabel = isDark ? "روشن" : "تاریک";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||||
title={`تغییر تم به حالت ${nextThemeLabel}`}
|
title={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useMemo, useState } from 'react';
|
"use client";
|
||||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
|
||||||
import { Menu, ChevronDown } from 'lucide-react';
|
import type { ReactNode } from "react";
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useMemo } from "react";
|
||||||
import { Button } from '@/components/ui/button';
|
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Link, NavLink } from "@/lib/router";
|
||||||
import ModeToggle from '@/components/ModeToggle';
|
import { useAuth } from "@/contexts/AuthContext";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
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 {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -13,190 +16,142 @@ import {
|
|||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const NavItem = ({
|
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
|
||||||
to,
|
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
to: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) => (
|
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
cn(
|
||||||
'px-2 py-1 rounded-md transition-colors',
|
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
isActive
|
||||||
].join(' ')
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Navbar() {
|
function ProfileAvatarMenu() {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { user, isAuthenticated } = useAuth();
|
const { user, isAuthenticated } = useAuth();
|
||||||
const isAdminUser = isAuthenticated && ((user?.is_staff || user?.is_superuser) ?? false);
|
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const avatarInitials = useMemo(
|
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],
|
[user?.first_name, user?.last_name, user?.username],
|
||||||
);
|
);
|
||||||
|
|
||||||
const UserDropdown = () => (
|
if (!isAuthenticated) {
|
||||||
<DropdownMenu>
|
return (
|
||||||
|
<Link to="/auth">
|
||||||
|
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu dir="rtl">
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<Button
|
||||||
type="button"
|
variant="ghost"
|
||||||
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"
|
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">
|
<Avatar className="h-10 w-10 border border-white/30 shadow-sm">
|
||||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
<AvatarImage
|
||||||
|
src={
|
||||||
|
user?.profile_picture_preview_url ||
|
||||||
|
user?.profile_picture ||
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
alt={user?.username || "profile"}
|
||||||
|
/>
|
||||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
</Button>
|
||||||
</button>
|
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-56" dir="rtl">
|
<DropdownMenuContent align="end" sideOffset={12} className="w-64 rounded-2xl p-2 text-right">
|
||||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
<DropdownMenuLabel className="text-right">
|
||||||
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
|
{[user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "حساب کاربری"}
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||||
<Link to="/profile">پروفایل</Link>
|
<Link to="/profile">
|
||||||
|
مشاهده پروفایل
|
||||||
|
<UserRound className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
{isAdminUser && (
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||||
<DropdownMenuItem asChild>
|
<Link to="/reset-password">
|
||||||
<Link to="/admin">داشبورد مدیریت</Link>
|
تغییر یا بازیابی رمز
|
||||||
|
<RotateCcw className="h-4 w-4" />
|
||||||
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
{isAdminUser ? (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||||
onSelect={(e) => e.preventDefault()}
|
<Link to="/admin">
|
||||||
className="flex items-center justify-between gap-2"
|
داشبورد مدیریت
|
||||||
>
|
<LayoutDashboard className="h-4 w-4" />
|
||||||
<span>حالت نمایش</span>
|
</Link>
|
||||||
<ModeToggle />
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive focus:text-destructive">
|
||||||
onSelect={(e) => {
|
<Link to="/logout">
|
||||||
e.preventDefault();
|
خروج از حساب
|
||||||
navigate('/logout');
|
<LogOut className="h-4 w-4" />
|
||||||
}}
|
</Link>
|
||||||
className="text-red-600 focus:text-red-600"
|
|
||||||
>
|
|
||||||
خروج
|
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const { isAuthenticated } = useAuth();
|
||||||
|
|
||||||
return (
|
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="container mx-auto px-4 py-3">
|
||||||
<div className="flex flex-row-reverse items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<Link to="/" className="order-2 flex items-center gap-2">
|
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||||
<span className="sm:inline text-2xl font-bold text-primary">
|
<div className="hidden rounded-2xl sm:flex">
|
||||||
انجمن علمی کامپیوتر گیلان
|
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
|
||||||
</span>
|
</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>
|
</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="/">خانه</NavItem>
|
||||||
<NavItem to="/blog">بلاگ</NavItem>
|
<NavItem to="/blog">بلاگ</NavItem>
|
||||||
<NavItem to="/events">رویدادها</NavItem>
|
<NavItem to="/events">رویدادها</NavItem>
|
||||||
{isAuthenticated ? (
|
|
||||||
<UserDropdown />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Link to="/auth">
|
|
||||||
<Button size="sm">ورود / ثبتنام</Button>
|
|
||||||
</Link>
|
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</>
|
{isAuthenticated ? <NotificationsBell /> : null}
|
||||||
)}
|
<ProfileAvatarMenu />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="order-1 md:hidden">
|
<div className="flex items-center gap-2 md:hidden">
|
||||||
<Sheet open={open} onOpenChange={setOpen}>
|
{isAuthenticated ? <NotificationsBell /> : null}
|
||||||
<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 />
|
<ModeToggle />
|
||||||
</div>
|
<ProfileAvatarMenu />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
184
src/components/NotificationsBell.tsx
Normal file
184
src/components/NotificationsBell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/components/OtpCodeField.tsx
Normal file
42
src/components/OtpCodeField.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/components/ProgressiveImage.tsx
Normal file
72
src/components/ProgressiveImage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
120
src/components/RouteProgress.tsx
Normal file
120
src/components/RouteProgress.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,56 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
"use client";
|
||||||
import * as React from 'react';
|
|
||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
export { ThemeProvider, useTheme } from "next-themes";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
200
src/components/page-loading.tsx
Normal file
200
src/components/page-loading.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
src/components/providers.tsx
Normal file
37
src/components/providers.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||||
import { cva } from "class-variance-authority";
|
import { cva } from "class-variance-authority";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { VariantProps, cva } from "class-variance-authority";
|
import { VariantProps, cva } from "class-variance-authority";
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { Toaster as Sonner, toast } from "sonner";
|
import { Toaster as Sonner, toast } from "sonner";
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
import * as TogglePrimitive from "@radix-ui/react-toggle";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
/* eslint-disable react-refresh/only-export-components */
|
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { UserProfileSchema } from '@/lib/types';
|
import type { UserProfileSchema } from '@/lib/types';
|
||||||
|
|
||||||
@@ -8,47 +7,83 @@ type User = UserProfileSchema;
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
loading: boolean;
|
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;
|
logout: () => void;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_KEY = 'access_token';
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
const clearSession = useCallback(() => {
|
||||||
checkAuth();
|
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
setUser(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const checkAuth = async () => {
|
const refreshProfile = useCallback(async () => {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
if (token) {
|
if (!token) {
|
||||||
|
setUser(null);
|
||||||
|
setLoading(false);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const profile = await api.getProfile();
|
const profile = await api.getProfile();
|
||||||
setUser(profile as User);
|
setUser(profile as User);
|
||||||
} catch (error) {
|
return profile as User;
|
||||||
localStorage.removeItem('access_token');
|
} catch {
|
||||||
localStorage.removeItem('refresh_token');
|
clearSession();
|
||||||
}
|
return null;
|
||||||
}
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
}
|
||||||
|
}, [clearSession]);
|
||||||
|
|
||||||
const login = async (email: string, password: string) => {
|
useEffect(() => {
|
||||||
const response = await api.login({ email, password });
|
void refreshProfile();
|
||||||
localStorage.setItem('access_token', response.access_token);
|
}, [refreshProfile]);
|
||||||
localStorage.setItem('refresh_token', response.refresh_token);
|
|
||||||
await checkAuth();
|
|
||||||
};
|
|
||||||
|
|
||||||
const logout = () => {
|
const setSessionTokens = useCallback(
|
||||||
localStorage.removeItem('access_token');
|
async (accessToken: string, refreshToken: string) => {
|
||||||
localStorage.removeItem('refresh_token');
|
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||||
setUser(null);
|
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 (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
@@ -56,6 +91,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
user,
|
user,
|
||||||
loading,
|
loading,
|
||||||
login,
|
login,
|
||||||
|
loginWithOtp,
|
||||||
|
setSessionTokens,
|
||||||
|
refreshProfile,
|
||||||
|
setUser,
|
||||||
logout,
|
logout,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
}}
|
}}
|
||||||
|
|||||||
388
src/contexts/NotificationsContext.tsx
Normal file
388
src/contexts/NotificationsContext.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -2,10 +2,6 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
* {
|
|
||||||
font-family: 'Vazirmatn', sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||||
All colors MUST be HSL.
|
All colors MUST be HSL.
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +53,7 @@ All colors MUST be HSL.
|
|||||||
--sidebar-border: 220 13% 91%;
|
--sidebar-border: 220 13% 91%;
|
||||||
|
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
--route-progress: 199 89% 48%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@@ -95,15 +92,20 @@ All colors MUST be HSL.
|
|||||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||||
--sidebar-border: 240 3.7% 15.9%;
|
--sidebar-border: 240 3.7% 15.9%;
|
||||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||||
|
--route-progress: 198 93% 60%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background font-sans text-foreground;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
711
src/lib/api.ts
711
src/lib/api.ts
@@ -1,7 +1,8 @@
|
|||||||
import type * as Types from './types';
|
import type * as Types from './types';
|
||||||
|
import { apiBaseUrl } from '@/lib/site';
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
|
apiBaseUrl;
|
||||||
|
|
||||||
type ApiErrorBody = {
|
type ApiErrorBody = {
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -18,8 +19,32 @@ class ApiClient {
|
|||||||
this.baseUrl = baseUrl;
|
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 {
|
private getAuthHeaders(): HeadersInit {
|
||||||
const token = localStorage.getItem('access_token');
|
const token = this.getStorageValue('access_token');
|
||||||
return {
|
return {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
@@ -27,7 +52,7 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAccessToken(): Promise<string> {
|
private async refreshAccessToken(): Promise<string> {
|
||||||
const refreshToken = localStorage.getItem('refresh_token');
|
const refreshToken = this.getStorageValue('refresh_token');
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
@@ -39,14 +64,14 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
localStorage.removeItem('access_token');
|
this.removeStorageValue('access_token');
|
||||||
localStorage.removeItem('refresh_token');
|
this.removeStorageValue('refresh_token');
|
||||||
throw new Error('Session expired. Please login again.');
|
throw new Error('Session expired. Please login again.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: Types.TokenSchema = await response.json();
|
const data: Types.TokenSchema = await response.json();
|
||||||
localStorage.setItem('access_token', data.access_token);
|
this.setStorageValue('access_token', data.access_token);
|
||||||
localStorage.setItem('refresh_token', data.refresh_token);
|
this.setStorageValue('refresh_token', data.refresh_token);
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +100,7 @@ class ApiClient {
|
|||||||
const response = await fetch(url, config);
|
const response = await fetch(url, config);
|
||||||
|
|
||||||
// Handle 401 with automatic token refresh
|
// 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) {
|
if (!this.isRefreshing) {
|
||||||
this.isRefreshing = true;
|
this.isRefreshing = true;
|
||||||
try {
|
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) {
|
async refreshToken(data: Types.TokenRefreshIn) {
|
||||||
return this.request<Types.TokenSchema>('/api/auth/refresh', {
|
return this.request<Types.TokenSchema>('/api/auth/refresh', {
|
||||||
method: 'POST',
|
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> {
|
async verifyEmail(token: string): Promise<Types.MessageSchema> {
|
||||||
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
|
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
|
||||||
const response = await fetch(url, { method: 'GET' });
|
const response = await fetch(url, { method: 'GET' });
|
||||||
@@ -184,7 +284,6 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProfile() {
|
async getProfile() {
|
||||||
const token = localStorage.getItem('access_token');
|
|
||||||
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -200,7 +299,7 @@ class ApiClient {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', file);
|
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`, {
|
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
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) {
|
async checkUsername(username: string) {
|
||||||
return this.request<Types.UsernameCheckSchema>(
|
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
|
// Admin auth endpoints
|
||||||
async listDeletedUsers() {
|
async listDeletedUsers() {
|
||||||
return this.request<Types.UserProfileSchema[]>('/api/auth/users/deleted');
|
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()}` : ''}`);
|
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 =============
|
// ============= Blog Endpoints =============
|
||||||
|
|
||||||
async getPosts(params?: {
|
async getPosts(params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
category?: string;
|
category?: string;
|
||||||
tag?: string;
|
tag?: string | string[];
|
||||||
search?: string;
|
search?: string;
|
||||||
featured?: boolean;
|
featured?: boolean;
|
||||||
author?: string;
|
author?: string | string[];
|
||||||
}) {
|
}) {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
if (params?.page) queryParams.append('page', params.page.toString());
|
if (params?.page) queryParams.append('page', params.page.toString());
|
||||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||||
if (params?.category) queryParams.append('category', params.category);
|
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?.search) queryParams.append('search', params.search);
|
||||||
if (params?.featured !== undefined) queryParams.append('featured', params.featured.toString());
|
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();
|
const query = queryParams.toString();
|
||||||
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
|
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getBlogFilters() {
|
||||||
|
return this.request<Types.BlogFiltersSchema>('/api/blog/filters');
|
||||||
|
}
|
||||||
|
|
||||||
async getPost(slug: string) {
|
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) {
|
async createPost(data: Types.PostCreateSchema) {
|
||||||
return this.request<Types.PostDetailSchema>('/api/blog/posts', {
|
return this.request<Types.PostDetailSchema>('/api/blog/admin/posts', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePost(slug: string, data: Types.PostCreateSchema) {
|
async updatePost(postId: number, data: Types.PostCreateSchema) {
|
||||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, {
|
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify(data),
|
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) {
|
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',
|
method: 'DELETE',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -341,16 +690,43 @@ class ApiClient {
|
|||||||
|
|
||||||
// Comments
|
// Comments
|
||||||
async getComments(slug: string) {
|
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) {
|
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',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
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() {
|
async listDeletedComments() {
|
||||||
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
|
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
|
||||||
}
|
}
|
||||||
@@ -363,13 +739,27 @@ class ApiClient {
|
|||||||
|
|
||||||
// Likes
|
// Likes
|
||||||
async toggleLike(slug: string) {
|
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',
|
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) {
|
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
|
// Categories
|
||||||
@@ -377,6 +767,30 @@ class ApiClient {
|
|||||||
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
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) {
|
async getCategory(slug: string) {
|
||||||
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
||||||
}
|
}
|
||||||
@@ -396,6 +810,30 @@ class ApiClient {
|
|||||||
return this.request<Types.TagSchema[]>('/api/blog/tags');
|
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) {
|
async getTag(slug: string) {
|
||||||
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
||||||
}
|
}
|
||||||
@@ -469,13 +907,75 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
|
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',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(data),
|
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) {
|
async deleteEvent(eventId: number) {
|
||||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
||||||
method: 'DELETE',
|
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 =============
|
// ============= Gallery Endpoints =============
|
||||||
|
|
||||||
async getGalleryImages(params?: {
|
async getGalleryImages(params?: {
|
||||||
@@ -614,7 +1152,7 @@ class ApiClient {
|
|||||||
if (data.description) formData.append('description', data.description);
|
if (data.description) formData.append('description', data.description);
|
||||||
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
|
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`, {
|
const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -639,12 +1177,86 @@ class ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMajors(): Promise<Types.MajorOption[]> {
|
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/majors', { method: 'GET' });
|
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[]> {
|
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||||
return this.request('/api/meta/universities', { method: 'GET' });
|
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) {
|
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);
|
export const api = new ApiClient(API_BASE_URL);
|
||||||
|
|||||||
17
src/lib/blog-routes.ts
Normal file
17
src/lib/blog-routes.ts
Normal 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
9
src/lib/helmet.tsx
Normal 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}</>;
|
||||||
|
}
|
||||||
52
src/lib/markdown-headings.ts
Normal file
52
src/lib/markdown-headings.ts
Normal 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[];
|
||||||
|
}
|
||||||
35
src/lib/navigation-progress.ts
Normal file
35
src/lib/navigation-progress.ts
Normal 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
143
src/lib/public-api.ts
Normal 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
198
src/lib/router.tsx
Normal 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
17
src/lib/site.ts
Normal 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}`;
|
||||||
|
};
|
||||||
656
src/lib/types.ts
656
src/lib/types.ts
@@ -12,20 +12,45 @@ export interface ErrorSchema {
|
|||||||
export interface TokenSchema {
|
export interface TokenSchema {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
token_type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MajorOption {
|
export interface MajorOption {
|
||||||
code: string;
|
code: string;
|
||||||
label: 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 {
|
export interface UserProfileSchema {
|
||||||
id: number;
|
id: number;
|
||||||
email: string;
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
student_id?: string | null;
|
student_id?: string | null;
|
||||||
year_of_study?: number;
|
year_of_study?: number;
|
||||||
@@ -34,70 +59,220 @@ export interface UserProfileSchema {
|
|||||||
date_joined: string;
|
date_joined: string;
|
||||||
|
|
||||||
is_email_verified?: boolean;
|
is_email_verified?: boolean;
|
||||||
|
is_mobile_verified?: boolean;
|
||||||
|
requires_mobile_verification?: boolean;
|
||||||
|
has_google_link?: boolean;
|
||||||
is_active?: boolean;
|
is_active?: boolean;
|
||||||
is_staff?: boolean;
|
is_staff?: boolean;
|
||||||
is_superuser?: boolean;
|
is_superuser?: boolean;
|
||||||
is_committee?: boolean;
|
is_committee?: boolean;
|
||||||
is_deleted?: boolean;
|
is_deleted?: boolean;
|
||||||
deleted_at?: string | null;
|
deleted_at?: string | null;
|
||||||
|
can_access_blog_admin?: boolean;
|
||||||
|
can_write_blog_posts?: boolean;
|
||||||
|
can_review_blog_posts?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserListSchema {
|
export interface UserListSchema {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
full_name?: string | null;
|
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_active: boolean;
|
||||||
is_staff: boolean;
|
is_staff: boolean;
|
||||||
is_superuser: boolean;
|
is_superuser: boolean;
|
||||||
date_joined: string;
|
date_joined: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserRegistrationSchema {
|
export interface AuthorizationRoleSchema {
|
||||||
email: string;
|
key: string;
|
||||||
password: string;
|
label: string;
|
||||||
|
description: string;
|
||||||
|
enabled: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserAuthorizationSchema {
|
||||||
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
student_id: string;
|
is_active: boolean;
|
||||||
year_of_study: number;
|
is_staff: boolean;
|
||||||
major: string;
|
is_superuser: boolean;
|
||||||
university: string;
|
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 = {
|
export type UserUpdateSchema = {
|
||||||
|
email?: string | null;
|
||||||
first_name?: string | null;
|
first_name?: string | null;
|
||||||
last_name?: string | null;
|
last_name?: string | null;
|
||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
year_of_study?: number | null;
|
year_of_study?: number | null;
|
||||||
major?: string | null;
|
major?: string | null;
|
||||||
university?: string | null;
|
university?: string | null;
|
||||||
student_id?: number | null;
|
student_id?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export interface UserLoginSchema {
|
export interface UserLoginSchema {
|
||||||
email: string;
|
identifier: string;
|
||||||
password: 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 {
|
export interface TokenRefreshIn {
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsernameCheckSchema {
|
export interface UsernameCheckSchema {
|
||||||
available: boolean;
|
exists: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PasswordResetRequestSchema {
|
export interface MobileLookupSchema {
|
||||||
email: string;
|
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;
|
token: string;
|
||||||
password: string;
|
expires_in: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blog Types
|
// Blog Types
|
||||||
@@ -107,19 +282,41 @@ export interface PostListSchema {
|
|||||||
slug: string;
|
slug: string;
|
||||||
excerpt?: string;
|
excerpt?: string;
|
||||||
featured_image?: 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: {
|
author: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
|
bio?: string | null;
|
||||||
profile_picture?: string;
|
profile_picture?: string;
|
||||||
|
profile_picture_thumbnail_url?: string | null;
|
||||||
|
profile_picture_preview_url?: string | null;
|
||||||
};
|
};
|
||||||
category?: {
|
category?: {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: 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<{
|
tags: Array<{
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -130,23 +327,72 @@ export interface PostListSchema {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
is_featured: boolean;
|
is_featured: boolean;
|
||||||
reading_time?: number;
|
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 {
|
export interface PostDetailSchema extends PostListSchema {
|
||||||
content: string;
|
content: string;
|
||||||
updated_at: string;
|
content_html?: string;
|
||||||
|
review_note?: string;
|
||||||
|
og_image_url?: string | null;
|
||||||
views_count?: number;
|
views_count?: number;
|
||||||
|
assets?: PostAssetSchema[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PostCreateSchema {
|
export interface PostCreateSchema {
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
summary: string;
|
excerpt?: string;
|
||||||
category_id?: number;
|
category_id?: number | null;
|
||||||
tag_ids?: number[];
|
tag_ids?: number[];
|
||||||
featured_image?: string;
|
writer_ids?: number[];
|
||||||
is_featured?: boolean;
|
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 {
|
export interface CommentSchema {
|
||||||
@@ -157,13 +403,24 @@ export interface CommentSchema {
|
|||||||
username: string;
|
username: string;
|
||||||
first_name: string;
|
first_name: string;
|
||||||
last_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_id: number;
|
||||||
post_title: string;
|
post_title: string;
|
||||||
post_slug: string;
|
post_slug: string;
|
||||||
parent_id?: number;
|
parent_id?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
is_approved: boolean;
|
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 {
|
export interface CommentCreateSchema {
|
||||||
@@ -171,14 +428,54 @@ export interface CommentCreateSchema {
|
|||||||
parent_id?: number;
|
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 {
|
export interface CategorySchema {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
parent_id?: number | null;
|
||||||
created_at: string;
|
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 {
|
export interface TagSchema {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -186,6 +483,45 @@ export interface TagSchema {
|
|||||||
created_at: string;
|
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
|
// Events Types
|
||||||
export interface EventListItemSchema {
|
export interface EventListItemSchema {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -194,6 +530,8 @@ export interface EventListItemSchema {
|
|||||||
description: string;
|
description: string;
|
||||||
featured_image?: string | null;
|
featured_image?: string | null;
|
||||||
absolute_featured_image_url?: 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';
|
event_type: 'online' | 'on_site' | 'hybrid';
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
location?: string | null;
|
location?: string | null;
|
||||||
@@ -205,6 +543,8 @@ export interface EventListItemSchema {
|
|||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
status: 'draft' | 'published' | 'cancelled' | 'completed';
|
status: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
status_label?: string;
|
||||||
|
event_type_label?: string;
|
||||||
registration_count: number;
|
registration_count: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
@@ -214,8 +554,16 @@ export interface EventGalleryItem {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
absolute_image_url?: string | null;
|
absolute_image_url?: string | null;
|
||||||
|
absolute_image_preview_url?: string | null;
|
||||||
|
absolute_image_blur_url?: string | null;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: 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 {
|
export interface EventDetailSchema extends EventListItemSchema {
|
||||||
@@ -228,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema {
|
|||||||
export interface EventCreateSchema {
|
export interface EventCreateSchema {
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
start_date: string;
|
slug?: string | null;
|
||||||
end_date?: string;
|
event_type: 'online' | 'on_site' | 'hybrid';
|
||||||
location: string;
|
address?: string | null;
|
||||||
capacity?: number;
|
location?: string | null;
|
||||||
event_image?: string;
|
online_link?: string | null;
|
||||||
requirements?: string;
|
start_time: string;
|
||||||
is_registration_open?: boolean;
|
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 {
|
export interface PaymentAdminSchema {
|
||||||
id: number;
|
id: number;
|
||||||
authority?: string | null;
|
authority?: string | null;
|
||||||
ref_id?: string | null;
|
ref_id?: string | null;
|
||||||
|
card_pan?: string | null;
|
||||||
|
card_hash?: string | null;
|
||||||
status: number;
|
status: number;
|
||||||
status_label: string;
|
status_label: string;
|
||||||
base_amount: number;
|
base_amount: number;
|
||||||
@@ -265,6 +622,14 @@ export interface RegistrationAdminSchema {
|
|||||||
first_name: string;
|
first_name: string;
|
||||||
last_name: string;
|
last_name: string;
|
||||||
email: 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[];
|
payments: PaymentAdminSchema[];
|
||||||
}
|
}
|
||||||
@@ -274,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
|
|||||||
}
|
}
|
||||||
export interface EventUpdateSchema {
|
export interface EventUpdateSchema {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
slug?: string | null;
|
||||||
description?: string;
|
description?: string;
|
||||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||||
address?: string | null;
|
address?: string | null;
|
||||||
@@ -286,9 +652,47 @@ export interface EventUpdateSchema {
|
|||||||
capacity?: number | null;
|
capacity?: number | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||||
|
registration_success_markdown?: string | null;
|
||||||
gallery_image_ids?: number[] | 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 {
|
export interface EventRegistrationSchema {
|
||||||
id: number;
|
id: number;
|
||||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||||
@@ -322,6 +726,11 @@ export interface GalleryImageSchema {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
image: 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: {
|
uploaded_by: {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -344,6 +753,197 @@ export interface PaginatedResponse<T> {
|
|||||||
previous?: string;
|
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
|
// payment
|
||||||
export interface CreatePaymentOut {
|
export interface CreatePaymentOut {
|
||||||
start_pay_url: string;
|
start_pay_url: string;
|
||||||
|
|||||||
104
src/lib/utils.ts
104
src/lib/utils.ts
@@ -35,11 +35,105 @@ export function formatJalali(iso?: string, withTime: boolean = true): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_THUMB = '/images/event-placeholder.svg';
|
export function formatJalaliDate(iso?: string): string {
|
||||||
export const getThumbUrl = (e: Types.EventListItemSchema) =>
|
if (!iso) return '—';
|
||||||
e.absolute_featured_image_url ||
|
try {
|
||||||
e.featured_image ||
|
const date = new Date(iso);
|
||||||
DEFAULT_THUMB;
|
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 = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user