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*
|
||||
|
||||
node_modules
|
||||
.next
|
||||
dist
|
||||
dist-ssr
|
||||
.env
|
||||
|
||||
46
README.md
46
README.md
@@ -1,43 +1,41 @@
|
||||
# Frontend
|
||||
|
||||
## Stack
|
||||
- Vite + React 18 with TypeScript.
|
||||
- `@tanstack/react-query` for data fetching and caching.
|
||||
- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS.
|
||||
- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers.
|
||||
- Next.js App Router with React 18 and TypeScript.
|
||||
- `@tanstack/react-query` for client-side authenticated flows.
|
||||
- Tailwind CSS and shadcn/ui components.
|
||||
- `next-themes`, Sonner, and toast helpers for RTL UI and notifications.
|
||||
|
||||
## Environment
|
||||
Copy `.env.sample` to `.env`.
|
||||
|
||||
Required variables:
|
||||
- `NEXT_PUBLIC_API_BASE_URL`
|
||||
- `NEXT_PUBLIC_SITE_URL`
|
||||
|
||||
## Development
|
||||
|
||||
### Install dependencies
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Configure API base URL
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
```
|
||||
The local dev server runs on `http://localhost:8080`.
|
||||
|
||||
### Run dev server
|
||||
```bash
|
||||
npm run dev -- --host
|
||||
```
|
||||
|
||||
### Production build
|
||||
## Production build
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
The Vite build reads `VITE_API_BASE_URL` from `.env`.
|
||||
The production runtime serves on port `3000` inside Docker. Dockerfiles live only in `guilan-ace-deployment`.
|
||||
|
||||
## Features
|
||||
- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments.
|
||||
- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion.
|
||||
- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog).
|
||||
## Routes
|
||||
- Public SEO pages: `/`, `/about`, `/blog`, `/blog/[slug]`, `/events`, `/events/[slug]`
|
||||
- Client-heavy flows: `/auth`, `/profile`, `/logout`, `/payments/result`, `/reset-password/*`, `/verify-email/*`
|
||||
- Admin: `/admin/*`
|
||||
|
||||
## Testing & linting
|
||||
## Validation
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import js from "@eslint/js";
|
||||
import nextPlugin from "@next/eslint-plugin-next";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{ ignores: ["dist", ".next", "next-env.d.ts"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
@@ -14,12 +14,12 @@ export default tseslint.config(
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"@next/next": nextPlugin,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...nextPlugin.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
|
||||
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,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"dev": "next dev --port 8080",
|
||||
"build": "next build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"start": "next start --port 3000"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/search": "^6.7.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.7",
|
||||
@@ -45,17 +51,22 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"input-otp": "^1.4.2",
|
||||
"jspdf": "^2.5.1",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.3.0",
|
||||
"react": "^18.3.1",
|
||||
"react-date-object": "^2.1.9",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet-async": "^2.0.5",
|
||||
"react-hook-form": "^7.61.1",
|
||||
"react-markdown": "^9.0.3",
|
||||
"react-multi-date-picker": "^4.5.2",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^6.30.1",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"recharts": "^2.15.4",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
@@ -64,28 +75,24 @@
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^0.9.9",
|
||||
"zod": "^3.25.76",
|
||||
"react-qr-code": "^2.0.11",
|
||||
"jspdf": "^2.5.1",
|
||||
"html2canvas": "^1.4.1"
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@next/eslint-plugin-next": "^16.2.6",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@types/node": "^22.16.5",
|
||||
"@types/react": "^18.3.26",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"@vitejs/plugin-react-swc": "^3.11.0",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.20",
|
||||
"globals": "^15.15.0",
|
||||
"lovable-tagger": "^1.1.10",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.38.0",
|
||||
"vite": "^5.4.19"
|
||||
"typescript-eslint": "^8.38.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import { Link } from "react-router-dom";
|
||||
"use client";
|
||||
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Instagram, Send, Twitter, Linkedin } from "lucide-react";
|
||||
import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح دادهام
|
||||
import { Instagram, Linkedin, Send, Twitter } from "lucide-react";
|
||||
|
||||
export default function Footer() {
|
||||
// const { toast } = useToast();
|
||||
// const [email, setEmail] = React.useState("");
|
||||
// const [loading, setLoading] = React.useState(false);
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
// const validateEmail = (v: string) =>
|
||||
// /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim());
|
||||
|
||||
// const onSubmit = async (e: React.FormEvent) => {
|
||||
// e.preventDefault();
|
||||
// const em = email.trim();
|
||||
// if (!validateEmail(em)) {
|
||||
// toast({ title: "ایمیل نامعتبر است", description: "لطفاً یک ایمیل صحیح وارد کنید.", variant: "destructive" });
|
||||
// return;
|
||||
// }
|
||||
// try {
|
||||
// setLoading(true);
|
||||
// const response = await api.subscribeNewsletter(em);
|
||||
|
||||
// if (response.success) {
|
||||
// toast({ title: "عضویت موفق", description: response.message });
|
||||
// setEmail("");
|
||||
// } else {
|
||||
// toast({ title: "عضویت ناموفق", description: response.message, variant: "destructive" });
|
||||
// }
|
||||
|
||||
// } catch (err: any) {
|
||||
// toast({ title: "خطا", description: err?.message || "مشکلی رخ داد.", variant: "destructive" });
|
||||
// } finally {
|
||||
// setLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
return (
|
||||
<footer className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40" dir="rtl">
|
||||
<footer
|
||||
className="border-t bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/40"
|
||||
dir="rtl"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-10">
|
||||
<div className="grid gap-8 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
{/* برند + درباره + اینماد */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-9 w-9 rounded" />
|
||||
<span className="text-xl font-bold">انجمن علمی کامپیوتر گیلان</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-7">
|
||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهٔ دانشجویی و پیوند با صنعت.
|
||||
ترویج علم کامپیوتر، برگزاری رویدادهای تخصصی، تقویت شبکهی دانشجویی و پیوند با صنعت.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
{/* لینکهای سریع */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">لینکهای مفید</h4>
|
||||
<ul className="space-y-2 text-sm">
|
||||
<li><Link to="/" className="text-muted-foreground hover:text-foreground">خانه</Link></li>
|
||||
<li><Link to="/events" className="text-muted-foreground hover:text-foreground">رویدادها</Link></li>
|
||||
<li><Link to="/blog" className="text-muted-foreground hover:text-foreground">بلاگ</Link></li>
|
||||
<li><Link to="/about" className="text-muted-foreground hover:text-foreground">دربارهٔ انجمن</Link></li>
|
||||
{/* <li><Link to="/contact" className="text-muted-foreground hover:text-foreground">تماس با ما</Link></li> */}
|
||||
{/* <li><Link to="/rules" className="text-muted-foreground hover:text-foreground">قوانین و حریم خصوصی</Link></li> */}
|
||||
<li>
|
||||
<Link to="/" className="text-muted-foreground hover:text-foreground">
|
||||
خانه
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/events" className="text-muted-foreground hover:text-foreground">
|
||||
رویدادها
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/blog" className="text-muted-foreground hover:text-foreground">
|
||||
بلاگ
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/about" className="text-muted-foreground hover:text-foreground">
|
||||
دربارهی انجمن
|
||||
</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* اطلاعات تماس / شبکههای اجتماعی */}
|
||||
<div>
|
||||
<h4 className="mb-3 text-base font-semibold">ارتباط با ما</h4>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
@@ -78,53 +58,49 @@ export default function Footer() {
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<a href="https://Instagram.com/guilance.ir" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<a
|
||||
href="https://Instagram.com/guilance.ir"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="اینستاگرام">
|
||||
<Instagram className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://t.me/guilance" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<a
|
||||
href="https://t.me/guilance"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="تلگرام">
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://www.linkedin.com/in/amiirkhl/" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<a
|
||||
href="https://www.linkedin.com/in/amiirkhl/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex"
|
||||
>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="لینکدین">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
<a href="https://x.com" target="_blank" rel="noreferrer" className="inline-flex">
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" aria-label="ایکس (توییتر)">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
aria-label="ایکس (توییتر)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خبرنامه */}
|
||||
{/* <div>
|
||||
<h4 className="mb-3 text-base font-semibold">عضویت در خبرنامه</h4>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
برای اطلاع از رویدادها و اخبار انجمن، ایمیل خود را وارد کنید.
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="flex flex-col sm:flex-row gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
inputMode="email"
|
||||
placeholder="ایمیل شما"
|
||||
dir="ltr"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
className="sm:flex-1 text-left"
|
||||
/>
|
||||
<Button type="submit" disabled={loading}>
|
||||
{loading ? "در حال عضویت..." : "عضویت"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
با عضویت، با <Link to="/rules" className="underline underline-offset-4">قوانین و حریم خصوصی</Link> موافقم.
|
||||
</p>
|
||||
</div> */}
|
||||
<div className="justify-self-end">
|
||||
<a
|
||||
href="https://trustseal.enamad.ir/?id=649977&Code=m0wWM1DFYqd4fLEnjyMU3o2pupfuqDVW"
|
||||
@@ -134,7 +110,7 @@ export default function Footer() {
|
||||
>
|
||||
<img
|
||||
src="/enamad.png"
|
||||
width="125px"
|
||||
width="125"
|
||||
alt="نماد اعتماد الکترونیکی"
|
||||
referrerPolicy="origin"
|
||||
style={{ cursor: "pointer" }}
|
||||
@@ -144,12 +120,10 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* خط جداکننده */}
|
||||
<div className="my-8 h-px w-full bg-border" />
|
||||
|
||||
{/* کپیرایت */}
|
||||
<div className="flex gap-2 items-center justify-center text-sm text-muted-foreground md:flex-row">
|
||||
<div>© {year} انجمن علمی کامپیوتر گیلان — تمامی حقوق محفوظ است.</div>
|
||||
<div>© {year} انجمن علمی کامپیوتر گیلان - تمامی حقوق محفوظ است.</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
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';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { PluggableList } from 'unified';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
"use client";
|
||||
|
||||
type MarkdownSize = 'sm' | 'base' | 'lg';
|
||||
import React, { useState } from "react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { PluggableList } from "unified";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize from "rehype-sanitize";
|
||||
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
|
||||
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
|
||||
import { extractMarkdownHeadings } from "@/lib/markdown-headings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownSize = "sm" | "base" | "lg";
|
||||
|
||||
type MarkdownProps = {
|
||||
content?: string;
|
||||
allowHtml?: boolean;
|
||||
className?: string;
|
||||
dir?: 'rtl' | 'ltr';
|
||||
dir?: "rtl" | "ltr";
|
||||
justify?: boolean;
|
||||
size?: MarkdownSize;
|
||||
};
|
||||
|
||||
function CodeBlock({
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const language = /language-([\w-]+)/.exec(className || "")?.[1] || "text";
|
||||
const code = String(children).replace(/\n$/, "");
|
||||
|
||||
const copyCode = async () => {
|
||||
if (!navigator.clipboard) return;
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="my-5 overflow-hidden rounded-2xl border border-slate-700/70 bg-[#0f172a] text-left shadow-xl shadow-slate-950/10"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/5 px-4 py-2 text-xs text-slate-300">
|
||||
<span className="font-mono uppercase tracking-wide">{language}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyCode}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-slate-300 transition hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<SyntaxHighlighter
|
||||
language={language}
|
||||
style={oneDark}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
margin: 0,
|
||||
background: "transparent",
|
||||
direction: "ltr",
|
||||
padding: "1rem",
|
||||
}}
|
||||
codeTagProps={{
|
||||
dir: "ltr",
|
||||
style: {
|
||||
direction: "ltr",
|
||||
textAlign: "left",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||
},
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</SyntaxHighlighter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Markdown({
|
||||
content = '',
|
||||
content = "",
|
||||
allowHtml = false,
|
||||
className = '',
|
||||
dir = 'rtl',
|
||||
className = "",
|
||||
dir = "rtl",
|
||||
justify = false,
|
||||
size = 'sm',
|
||||
size = "sm",
|
||||
}: MarkdownProps) {
|
||||
const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined;
|
||||
const headings = extractMarkdownHeadings(content);
|
||||
let headingIndex = 0;
|
||||
|
||||
const baseSizeClass =
|
||||
size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base';
|
||||
size === "sm" ? "text-sm" : size === "lg" ? "text-lg" : "text-base";
|
||||
|
||||
const hScale =
|
||||
size === 'sm'
|
||||
? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' }
|
||||
: size === 'base'
|
||||
? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' }
|
||||
: { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' };
|
||||
size === "sm"
|
||||
? { h1: "text-xl", h2: "text-lg", h3: "text-base", h4: "text-base" }
|
||||
: size === "base"
|
||||
? { h1: "text-3xl", h2: "text-2xl", h3: "text-xl", h4: "text-lg" }
|
||||
: { h1: "text-4xl", h2: "text-3xl", h3: "text-2xl", h4: "text-xl" };
|
||||
|
||||
const justifyStyle: React.CSSProperties | undefined = justify
|
||||
? { textAlign: 'justify', textJustify: 'inter-word' }
|
||||
? { textAlign: "justify", textJustify: "inter-word" }
|
||||
: undefined;
|
||||
|
||||
const nextHeadingId = (level: 1 | 2 | 3) => {
|
||||
while (headingIndex < headings.length) {
|
||||
const heading = headings[headingIndex];
|
||||
headingIndex += 1;
|
||||
if (heading.level === level) return heading.id;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
dir={dir}
|
||||
className={`markdown-body ${baseSizeClass} text-right leading-7 break-words ${className}`}
|
||||
className={cn("markdown-body break-words text-right leading-8", baseSizeClass, className)}
|
||||
style={justifyStyle}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
components={{
|
||||
h1: (p) => <h1 className={`mt-6 font-bold ${hScale.h1}`} {...p} />,
|
||||
h2: (p) => <h2 className={`mt-6 font-bold ${hScale.h2}`} {...p} />,
|
||||
h3: (p) => <h3 className={`mt-5 font-semibold ${hScale.h3}`} {...p} />,
|
||||
h4: (p) => <h4 className={`mt-4 font-semibold ${hScale.h4}`} {...p} />,
|
||||
p: (p) => <p className="my-3" {...p} />,
|
||||
a: (p) => <a className="underline decoration-primary hover:opacity-90 break-all" target="_blank" rel="noopener noreferrer" {...p} />,
|
||||
ul: (p) => <ul className="my-3 list-disc ps-6 space-y-1.5" {...p} />,
|
||||
ol: (p) => <ol className="my-3 list-decimal ps-6 space-y-1.5" {...p} />,
|
||||
li: (p) => <li className="[&>ul]:my-1.5 [&>ol]:my-1.5" {...p} />,
|
||||
hr: (p) => <hr className="my-5 border-muted" {...p} />,
|
||||
h1: (p) => <h1 {...p} id={nextHeadingId(1)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h1)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h2: (p) => <h2 {...p} id={nextHeadingId(2)} className={cn("scroll-mt-28 pt-2 text-right font-bold", hScale.h2)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h3: (p) => <h3 {...p} id={nextHeadingId(3)} className={cn("scroll-mt-28 pt-2 text-right font-semibold", hScale.h3)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
h4: (p) => <h4 {...p} className={cn("mt-4 text-right font-semibold", hScale.h4)} style={{ ...p.style, textAlign: "right" }} />,
|
||||
p: (p) => <p className="my-4" {...p} />,
|
||||
a: (p) => <a className="break-all underline decoration-primary hover:opacity-90" target="_blank" rel="noopener noreferrer" {...p} />,
|
||||
ul: (p) => <ul className="my-4 list-disc space-y-1.5 pe-0 ps-6" {...p} />,
|
||||
ol: (p) => <ol className="my-4 list-decimal space-y-1.5 pe-0 ps-6" {...p} />,
|
||||
li: (p) => <li className="[&>ol]:my-1.5 [&>ul]:my-1.5" {...p} />,
|
||||
hr: (p) => <hr className="my-6 border-muted" {...p} />,
|
||||
blockquote: (p) => (
|
||||
<blockquote className="my-3 border-r-4 pr-4 italic text-muted-foreground" {...p} />
|
||||
<blockquote className="my-4 rounded-2xl border-r-4 border-primary bg-muted/40 py-3 pr-4 italic text-muted-foreground" {...p} />
|
||||
),
|
||||
code: ({ className, children, node, ...p }) => {
|
||||
const isInline =
|
||||
node?.tagName === 'code' &&
|
||||
!/language-/.test(className || '') &&
|
||||
!String(children).includes('\n');
|
||||
node?.tagName === "code" &&
|
||||
!/language-/.test(className || "") &&
|
||||
!String(children).includes("\n");
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-[0.9em]" {...p}>
|
||||
<code dir="ltr" className="rounded bg-muted px-1.5 py-0.5 text-[0.9em]" {...p}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<code className={className} {...p}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
return <CodeBlock className={className}>{children}</CodeBlock>;
|
||||
},
|
||||
pre: ({ className = '', children, ...p }) => (
|
||||
<pre
|
||||
className={[
|
||||
"my-4 overflow-x-auto rounded-md bg-muted p-4 text-[0.9em]",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
{...p}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
pre: ({ children }) => <>{children}</>,
|
||||
table: (p) => (
|
||||
<div className="my-3 overflow-x-auto">
|
||||
<div className="my-4 overflow-x-auto">
|
||||
<table className="w-full border-collapse" {...p} />
|
||||
</div>
|
||||
),
|
||||
|
||||
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 { Moon, Sun } from 'lucide-react';
|
||||
import { useTheme } from '@/components/ThemeProvider';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTheme } from "@/components/ThemeProvider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
export default function ModeToggle() {
|
||||
export default function ModeToggle({ className }: { className?: string }) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const handleToggle = () => {
|
||||
if (theme === 'system' && typeof window !== 'undefined') {
|
||||
const prefersDark = window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||
setTheme(prefersDark ? 'light' : 'dark');
|
||||
if (theme === "system" && typeof window !== "undefined") {
|
||||
const prefersDark = window.matchMedia?.("(prefers-color-scheme: dark)").matches ?? false;
|
||||
setTheme(prefersDark ? "light" : "dark");
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
setTheme(theme === "dark" ? "light" : "dark");
|
||||
};
|
||||
|
||||
const isDark =
|
||||
theme === 'dark' ||
|
||||
(theme === 'system' &&
|
||||
typeof document !== 'undefined' &&
|
||||
document.documentElement.classList.contains('dark'));
|
||||
theme === "dark" ||
|
||||
(theme === "system" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.classList.contains("dark"));
|
||||
|
||||
const nextThemeLabel = isDark ? 'روشن' : 'تاریک';
|
||||
const nextThemeLabel = isDark ? "روشن" : "تاریک";
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"rounded-full border-0 bg-transparent shadow-none transition hover:bg-background/45 hover:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
aria-label={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
title={`تغییر تم به حالت ${nextThemeLabel}`}
|
||||
onClick={handleToggle}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { Menu, ChevronDown } from 'lucide-react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import ModeToggle from '@/components/ModeToggle';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { LayoutDashboard, LogOut, RotateCcw, UserRound } from "lucide-react";
|
||||
import { Link, NavLink } from "@/lib/router";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import ModeToggle from "@/components/ModeToggle";
|
||||
import NotificationsBell from "@/components/NotificationsBell";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -13,190 +16,142 @@ import {
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const NavItem = ({
|
||||
to,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
to: string;
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => (
|
||||
const NavItem = ({ to, children }: { to: string; children: ReactNode }) => (
|
||||
<NavLink
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
[
|
||||
'px-2 py-1 rounded-md transition-colors',
|
||||
isActive ? 'text-primary' : 'text-muted-foreground hover:text-foreground',
|
||||
].join(' ')
|
||||
cn(
|
||||
"rounded-full px-3 py-2 text-sm font-medium transition-colors",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
)
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
|
||||
export default function Navbar() {
|
||||
const navigate = useNavigate();
|
||||
function ProfileAvatarMenu() {
|
||||
const { user, isAuthenticated } = useAuth();
|
||||
const isAdminUser = isAuthenticated && ((user?.is_staff || user?.is_superuser) ?? false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const isAdminUser = isAuthenticated && Boolean(user?.is_staff || user?.is_superuser);
|
||||
|
||||
const avatarInitials = useMemo(
|
||||
() => (user?.first_name?.[0] || user?.last_name?.[0] || user?.username?.[0] || '?').toUpperCase(),
|
||||
() =>
|
||||
(user?.first_name?.[0] ||
|
||||
user?.last_name?.[0] ||
|
||||
user?.username?.[0] ||
|
||||
"?").toUpperCase(),
|
||||
[user?.first_name, user?.last_name, user?.username],
|
||||
);
|
||||
|
||||
const UserDropdown = () => (
|
||||
<DropdownMenu>
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<Link to="/auth">
|
||||
<Button className="rounded-full px-5">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu dir="rtl">
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-full border border-border/60 bg-muted/40 px-2 py-1 pr-2.5 transition hover:bg-muted"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 rounded-full border-0 bg-transparent p-0 shadow-none transition hover:bg-background/45"
|
||||
aria-label="منوی حساب کاربری"
|
||||
>
|
||||
<Avatar className="h-9 w-9">
|
||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
||||
<Avatar className="h-10 w-10 border border-white/30 shadow-sm">
|
||||
<AvatarImage
|
||||
src={
|
||||
user?.profile_picture_preview_url ||
|
||||
user?.profile_picture ||
|
||||
undefined
|
||||
}
|
||||
alt={user?.username || "profile"}
|
||||
/>
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56" dir="rtl">
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{user?.first_name || user?.last_name ? `${user?.first_name || ''} ${user?.last_name || ''}`.trim() : user?.username}
|
||||
<DropdownMenuContent align="end" sideOffset={12} className="w-64 rounded-2xl p-2 text-right">
|
||||
<DropdownMenuLabel className="text-right">
|
||||
{[user?.first_name, user?.last_name].filter(Boolean).join(" ") || user?.username || "حساب کاربری"}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/profile">
|
||||
مشاهده پروفایل
|
||||
<UserRound className="h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdminUser && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/admin">داشبورد مدیریت</Link>
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/reset-password">
|
||||
تغییر یا بازیابی رمز
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{isAdminUser ? (
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl">
|
||||
<Link to="/admin">
|
||||
داشبورد مدیریت
|
||||
<LayoutDashboard className="h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-between gap-2"
|
||||
>
|
||||
<span>حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => {
|
||||
e.preventDefault();
|
||||
navigate('/logout');
|
||||
}}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
خروج
|
||||
<DropdownMenuItem asChild className="flex-row-reverse justify-end gap-2 rounded-xl text-destructive focus:text-destructive">
|
||||
<Link to="/logout">
|
||||
خروج از حساب
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Navbar() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
return (
|
||||
<nav className="sticky top-0 z-40 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60" dir="rtl">
|
||||
<nav
|
||||
className="sticky top-0 z-40 border-b bg-background/75 backdrop-blur-xl supports-[backdrop-filter]:bg-background/55"
|
||||
dir="rtl"
|
||||
>
|
||||
<div className="container mx-auto px-4 py-3">
|
||||
<div className="flex flex-row-reverse items-center justify-between gap-3">
|
||||
<Link to="/" className="order-2 flex items-center gap-2">
|
||||
<span className="sm:inline text-2xl font-bold text-primary">
|
||||
انجمن علمی کامپیوتر گیلان
|
||||
</span>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Link to="/" className="flex min-w-0 items-center gap-3">
|
||||
<div className="hidden rounded-2xl sm:flex">
|
||||
<img src="/favicon.ico" alt="لوگوی انجمن" className="h-10 w-10 object-contain" />
|
||||
</div>
|
||||
<div className="min-w-0 text-right">
|
||||
<p className="truncate text-sm font-semibold text-foreground sm:text-base">
|
||||
انجمن علمی مهندسی کامپیوتر
|
||||
</p>
|
||||
<p className="hidden text-xs text-muted-foreground sm:block">
|
||||
دانشکدهی فنی و مهندسی شرق گیلان
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="order-1 hidden md:flex items-center gap-2">
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<NavItem to="/">خانه</NavItem>
|
||||
<NavItem to="/blog">بلاگ</NavItem>
|
||||
<NavItem to="/events">رویدادها</NavItem>
|
||||
{isAuthenticated ? (
|
||||
<UserDropdown />
|
||||
) : (
|
||||
<>
|
||||
<Link to="/auth">
|
||||
<Button size="sm">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
<ModeToggle />
|
||||
</>
|
||||
)}
|
||||
<ModeToggle />
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
|
||||
<div className="order-1 md:hidden">
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" aria-label="U.U+U^">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[80vw] sm:w-[360px]" dir="rtl">
|
||||
<div className="mt-6 flex flex-col gap-4 text-right">
|
||||
<Link
|
||||
to="/"
|
||||
onClick={() => setOpen(false)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<img src="/favicon.ico" alt="لوگو" className="h-8 w-auto" height={32} width={32} />
|
||||
<span className="text-xl font-semibold text-primary">انجمن علمی کامپیوتر گیلان</span>
|
||||
</Link>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<NavItem to="/" onClick={() => setOpen(false)}>خانه</NavItem>
|
||||
<NavItem to="/blog" onClick={() => setOpen(false)}>بلاگ</NavItem>
|
||||
<NavItem to="/events" onClick={() => setOpen(false)}>رویدادها</NavItem>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t grid gap-3">
|
||||
{isAuthenticated ? (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-md border px-3 py-2">
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarImage src={user?.profile_picture || undefined} alt={user?.username || 'profile'} />
|
||||
<AvatarFallback>{avatarInitials}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">{user?.username}</div>
|
||||
{user?.email ? <div className="text-xs text-muted-foreground">{user.email}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
||||
<Link to="/profile">پروفایل</Link>
|
||||
</Button>
|
||||
{isAdminUser && (
|
||||
<Button variant="ghost" className="justify-between" asChild onClick={() => setOpen(false)}>
|
||||
<Link to="/admin">داشبورد مدیریت</Link>
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-between text-red-600 border-red-600 hover:bg-red-50 dark:text-red-400 dark:border-red-400 dark:hover:bg-red-950/30"
|
||||
onClick={() => { setOpen(false); navigate('/logout'); }}
|
||||
>
|
||||
خروج
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<Link to="/auth" onClick={() => setOpen(false)}>
|
||||
<Button className="w-full">ورود / ثبتنام</Button>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between rounded-md border px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">حالت نمایش</span>
|
||||
<ModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
{isAuthenticated ? <NotificationsBell /> : null}
|
||||
<ModeToggle />
|
||||
<ProfileAvatarMenu />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
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 */
|
||||
import * as React from 'react';
|
||||
"use client";
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
type Ctx = { theme: Theme; setTheme: (t: Theme) => void };
|
||||
const ThemeContext = React.createContext<Ctx | null>(null);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'egce-theme',
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
}) {
|
||||
const [theme, setTheme] = React.useState<Theme>(() => {
|
||||
try { return (localStorage.getItem(storageKey) as Theme) || defaultTheme; }
|
||||
catch { return defaultTheme; }
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const apply = (t: Theme) => {
|
||||
const isDark = t === 'system' ? mql.matches : t === 'dark';
|
||||
root.classList.toggle('dark', isDark);
|
||||
};
|
||||
|
||||
apply(theme);
|
||||
const onChange = () => theme === 'system' && apply('system');
|
||||
mql.addEventListener('change', onChange);
|
||||
return () => mql.removeEventListener('change', onChange);
|
||||
}, [theme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
} catch (error) {
|
||||
console.warn('Unable to persist theme preference', error);
|
||||
}
|
||||
}, [theme, storageKey]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const ctx = React.useContext(ThemeContext);
|
||||
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
|
||||
return ctx;
|
||||
}
|
||||
export { ThemeProvider, useTheme } from "next-themes";
|
||||
|
||||
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 { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { VariantProps, cva } from "class-variance-authority";
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { useTheme } from "next-themes";
|
||||
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 TogglePrimitive from "@radix-ui/react-toggle";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useCallback } from 'react';
|
||||
import { api } from '@/lib/api';
|
||||
import type { UserProfileSchema } from '@/lib/types';
|
||||
|
||||
@@ -8,47 +7,83 @@ type User = UserProfileSchema;
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
login: (identifier: string, password: string) => Promise<void>;
|
||||
loginWithOtp: (mobile: string, code: string) => Promise<void>;
|
||||
setSessionTokens: (accessToken: string, refreshToken: string) => Promise<void>;
|
||||
refreshProfile: () => Promise<User | null>;
|
||||
setUser: (user: User | null) => void;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
const ACCESS_TOKEN_KEY = 'access_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
const clearSession = useCallback(() => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
try {
|
||||
const profile = await api.getProfile();
|
||||
setUser(profile as User);
|
||||
} catch (error) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
}
|
||||
const refreshProfile = useCallback(async () => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (!token) {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
return null;
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await api.login({ email, password });
|
||||
localStorage.setItem('access_token', response.access_token);
|
||||
localStorage.setItem('refresh_token', response.refresh_token);
|
||||
await checkAuth();
|
||||
};
|
||||
try {
|
||||
const profile = await api.getProfile();
|
||||
setUser(profile as User);
|
||||
return profile as User;
|
||||
} catch {
|
||||
clearSession();
|
||||
return null;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clearSession]);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
setUser(null);
|
||||
};
|
||||
useEffect(() => {
|
||||
void refreshProfile();
|
||||
}, [refreshProfile]);
|
||||
|
||||
const setSessionTokens = useCallback(
|
||||
async (accessToken: string, refreshToken: string) => {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
setLoading(true);
|
||||
await refreshProfile();
|
||||
},
|
||||
[refreshProfile],
|
||||
);
|
||||
|
||||
const login = useCallback(
|
||||
async (identifier: string, password: string) => {
|
||||
const response = await api.login({ identifier, password });
|
||||
await setSessionTokens(response.access_token, response.refresh_token);
|
||||
},
|
||||
[setSessionTokens],
|
||||
);
|
||||
|
||||
const loginWithOtp = useCallback(
|
||||
async (mobile: string, code: string) => {
|
||||
const response = await api.loginWithOtp({ mobile, code });
|
||||
await setSessionTokens(response.access_token, response.refresh_token);
|
||||
},
|
||||
[setSessionTokens],
|
||||
);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearSession();
|
||||
}, [clearSession]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
@@ -56,6 +91,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
user,
|
||||
loading,
|
||||
login,
|
||||
loginWithOtp,
|
||||
setSessionTokens,
|
||||
refreshProfile,
|
||||
setUser,
|
||||
logout,
|
||||
isAuthenticated: !!user,
|
||||
}}
|
||||
|
||||
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 utilities;
|
||||
|
||||
* {
|
||||
font-family: 'Vazirmatn', sans-serif;
|
||||
}
|
||||
|
||||
/* Definition of the design system. All colors, gradients, fonts, etc should be defined here.
|
||||
All colors MUST be HSL.
|
||||
*/
|
||||
@@ -57,6 +53,7 @@ All colors MUST be HSL.
|
||||
--sidebar-border: 220 13% 91%;
|
||||
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--route-progress: 199 89% 48%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@@ -95,15 +92,20 @@ All colors MUST be HSL.
|
||||
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
||||
--sidebar-border: 240 3.7% 15.9%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
--route-progress: 198 93% 60%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background font-sans text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
711
src/lib/api.ts
711
src/lib/api.ts
@@ -1,7 +1,8 @@
|
||||
import type * as Types from './types';
|
||||
import { apiBaseUrl } from '@/lib/site';
|
||||
|
||||
const API_BASE_URL =
|
||||
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') || 'https://api.east-guilan-ce.ir';
|
||||
apiBaseUrl;
|
||||
|
||||
type ApiErrorBody = {
|
||||
error?: string;
|
||||
@@ -18,8 +19,32 @@ class ApiClient {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
private getStorageValue(key: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage.getItem(key);
|
||||
}
|
||||
|
||||
private setStorageValue(key: string, value: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(key, value);
|
||||
}
|
||||
|
||||
private removeStorageValue(key: string) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(key);
|
||||
}
|
||||
|
||||
private getAuthHeaders(): HeadersInit {
|
||||
const token = localStorage.getItem('access_token');
|
||||
const token = this.getStorageValue('access_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
@@ -27,7 +52,7 @@ class ApiClient {
|
||||
}
|
||||
|
||||
private async refreshAccessToken(): Promise<string> {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
const refreshToken = this.getStorageValue('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
@@ -39,14 +64,14 @@ class ApiClient {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
this.removeStorageValue('access_token');
|
||||
this.removeStorageValue('refresh_token');
|
||||
throw new Error('Session expired. Please login again.');
|
||||
}
|
||||
|
||||
const data: Types.TokenSchema = await response.json();
|
||||
localStorage.setItem('access_token', data.access_token);
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
this.setStorageValue('access_token', data.access_token);
|
||||
this.setStorageValue('refresh_token', data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
@@ -75,7 +100,7 @@ class ApiClient {
|
||||
const response = await fetch(url, config);
|
||||
|
||||
// Handle 401 with automatic token refresh
|
||||
if (response.status === 401 && localStorage.getItem('refresh_token')) {
|
||||
if (response.status === 401 && this.getStorageValue('refresh_token')) {
|
||||
if (!this.isRefreshing) {
|
||||
this.isRefreshing = true;
|
||||
try {
|
||||
@@ -154,6 +179,27 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async loginWithOtp(data: Types.UserOtpLoginSchema) {
|
||||
return this.request<Types.TokenSchema>('/api/auth/login/otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async sendOtp(data: Types.OtpSendSchema) {
|
||||
return this.request<Types.OtpSendResponseSchema>('/api/auth/otp/send', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyRegisterOtp(data: Types.RegisterOtpVerifySchema) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/otp/verify-register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async refreshToken(data: Types.TokenRefreshIn) {
|
||||
return this.request<Types.TokenSchema>('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
@@ -161,6 +207,60 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async resetPassword(data: Types.PasswordResetSchema) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/reset-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async sendMobileVerificationOtp(data: Types.MobileOtpSendSchema) {
|
||||
return this.request<Types.OtpSendResponseSchema>('/api/auth/mobile/send-otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyMobile(data: Types.MobileOtpVerifySchema) {
|
||||
return this.request<Types.UserProfileSchema>('/api/auth/mobile/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async startGoogleLogin() {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = `${this.baseUrl}/api/auth/oauth/google/start`;
|
||||
}
|
||||
}
|
||||
|
||||
async getGoogleFlow(flow: string) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>(
|
||||
`/api/auth/oauth/google/flow?flow=${encodeURIComponent(flow)}`
|
||||
);
|
||||
}
|
||||
|
||||
async completeGoogleSignup(data: Types.GoogleCompleteSchema) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/complete', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async resendGoogleClaimOtp(flow: string) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/oauth/google/claim/send-otp', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flow }),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyGoogleClaim(flow: string, code: string) {
|
||||
return this.request<Types.GoogleFlowResponseSchema>('/api/auth/oauth/google/claim/verify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flow, code }),
|
||||
});
|
||||
}
|
||||
|
||||
async verifyEmail(token: string): Promise<Types.MessageSchema> {
|
||||
const url = `${this.baseUrl}/api/auth/verify-email/${encodeURIComponent(token)}`;
|
||||
const response = await fetch(url, { method: 'GET' });
|
||||
@@ -184,7 +284,6 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async getProfile() {
|
||||
const token = localStorage.getItem('access_token');
|
||||
return this.request<Types.UserProfileSchema>('/api/auth/profile'
|
||||
);
|
||||
}
|
||||
@@ -200,7 +299,7 @@ class ApiClient {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/auth/profile/picture`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -241,6 +340,17 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getLegacyVerifyEmailMessage(token: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/auth/verify-email/${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
async getLegacyResetTokenMessage(token: string) {
|
||||
return this.request<Types.MessageSchema>('/api/auth/reset-password-confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async checkUsername(username: string) {
|
||||
return this.request<Types.UsernameCheckSchema>(
|
||||
@@ -248,6 +358,12 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async checkMobile(mobile: string) {
|
||||
return this.request<Types.MobileLookupSchema>(
|
||||
`/api/auth/check-mobile?mobile=${encodeURIComponent(mobile)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Admin auth endpoints
|
||||
async listDeletedUsers() {
|
||||
return this.request<Types.UserProfileSchema[]>('/api/auth/users/deleted');
|
||||
@@ -281,50 +397,283 @@ class ApiClient {
|
||||
return this.request<Types.UserListSchema[]>(`/api/auth/users${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async getUserDetail(userId: number) {
|
||||
return this.request<Types.UserProfileSchema>(`/api/auth/users/${userId}`);
|
||||
}
|
||||
|
||||
async listAuthorizationRoles() {
|
||||
return this.request<Types.AuthorizationRoleSchema[]>('/api/auth/roles');
|
||||
}
|
||||
|
||||
async getUserAuthorization(userId: number) {
|
||||
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`);
|
||||
}
|
||||
|
||||
async updateUserAuthorization(userId: number, data: Types.UserAuthorizationUpdateSchema) {
|
||||
return this.request<Types.UserAuthorizationSchema>(`/api/auth/users/${userId}/authorization`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async getAdminDashboard(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
event_id?: number;
|
||||
granularity?: 'auto' | 'day' | 'week' | 'month';
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
if (params?.granularity) query.set('granularity', params.granularity);
|
||||
return this.request<Types.AdminDashboardAnalyticsSchema>(
|
||||
`/api/analytics/admin/dashboard${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminUserAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.UserAnalyticsSchema>(
|
||||
`/api/analytics/admin/users${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminEventAnalytics(params?: { date_from?: string; date_to?: string; event_id?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
if (params?.event_id != null) query.set('event_id', String(params.event_id));
|
||||
return this.request<Types.EventAnalyticsSchema>(
|
||||
`/api/analytics/admin/events${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminBlogAnalytics(params?: { date_from?: string; date_to?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.date_from) query.set('date_from', params.date_from);
|
||||
if (params?.date_to) query.set('date_to', params.date_to);
|
||||
return this.request<Types.BlogAnalyticsSchema>(
|
||||
`/api/analytics/admin/blog${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminDashboardEventOptions(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.AnalyticsEventOptionsSchema>(
|
||||
`/api/analytics/admin/events/options${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ============= Blog Endpoints =============
|
||||
|
||||
async getPosts(params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
category?: string;
|
||||
tag?: string;
|
||||
tag?: string | string[];
|
||||
search?: string;
|
||||
featured?: boolean;
|
||||
author?: string;
|
||||
author?: string | string[];
|
||||
}) {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.append('page', params.page.toString());
|
||||
if (params?.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params?.category) queryParams.append('category', params.category);
|
||||
if (params?.tag) queryParams.append('tag', params.tag);
|
||||
if (Array.isArray(params?.tag)) {
|
||||
params.tag.forEach((tag) => queryParams.append('tag', tag));
|
||||
} else if (params?.tag) {
|
||||
queryParams.append('tag', params.tag);
|
||||
}
|
||||
if (params?.search) queryParams.append('search', params.search);
|
||||
if (params?.featured !== undefined) queryParams.append('featured', params.featured.toString());
|
||||
if (params?.author) queryParams.append('author', params.author);
|
||||
if (Array.isArray(params?.author)) {
|
||||
params.author.forEach((author) => queryParams.append('author', author));
|
||||
} else if (params?.author) {
|
||||
queryParams.append('author', params.author);
|
||||
}
|
||||
|
||||
const query = queryParams.toString();
|
||||
return this.request<Types.PostListSchema[]>(`/api/blog/posts${query ? `?${query}` : ''}`);
|
||||
}
|
||||
|
||||
async getBlogFilters() {
|
||||
return this.request<Types.BlogFiltersSchema>('/api/blog/filters');
|
||||
}
|
||||
|
||||
async getPost(slug: string) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`);
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`);
|
||||
}
|
||||
|
||||
async createPost(data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>('/api/blog/posts', {
|
||||
return this.request<Types.PostDetailSchema>('/api/blog/admin/posts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updatePost(slug: string, data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/posts/${slug}`, {
|
||||
async updatePost(postId: number, data: Types.PostCreateSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminBlogPosts(params?: {
|
||||
status?: string;
|
||||
search?: string;
|
||||
mine?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.mine != null) query.set('mine', String(params.mine));
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PostListSchema[]>(`/api/blog/admin/posts${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async listBlogWriters() {
|
||||
return this.request<NonNullable<Types.PostListSchema['writers']>>('/api/blog/admin/writers');
|
||||
}
|
||||
|
||||
async getAdminBlogPost(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}`);
|
||||
}
|
||||
|
||||
async submitBlogPost(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/submit`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async reviewBlogPost(postId: number, data: Types.PostReviewSchema) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/review`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async listBlogPostAssets(postId: number) {
|
||||
return this.request<Types.PostAssetSchema[]>(`/api/blog/admin/posts/${postId}/assets`);
|
||||
}
|
||||
|
||||
async uploadBlogPostFeaturedImage(postId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(error.error || error.detail || 'Featured image upload failed');
|
||||
}
|
||||
|
||||
return response.json() as Promise<Types.PostDetailSchema>;
|
||||
}
|
||||
|
||||
async deleteBlogPostFeaturedImage(postId: number) {
|
||||
return this.request<Types.PostDetailSchema>(`/api/blog/admin/posts/${postId}/featured-image`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async uploadBlogPostAsset(
|
||||
postId: number,
|
||||
file: File,
|
||||
data: { title?: string; alt_text?: string; caption?: string } = {},
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', data.title ?? '');
|
||||
formData.append('alt_text', data.alt_text ?? '');
|
||||
formData.append('caption', data.caption ?? '');
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/blog/admin/posts/${postId}/assets`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(error.error || error.detail || 'Asset upload failed');
|
||||
}
|
||||
|
||||
return response.json() as Promise<Types.PostAssetSchema>;
|
||||
}
|
||||
|
||||
uploadBlogPostAssetWithProgress(
|
||||
postId: number,
|
||||
file: File,
|
||||
data: { title?: string; alt_text?: string; caption?: string } = {},
|
||||
onProgress?: (progress: number) => void,
|
||||
) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('title', data.title ?? '');
|
||||
formData.append('alt_text', data.alt_text ?? '');
|
||||
formData.append('caption', data.caption ?? '');
|
||||
|
||||
const token = this.getStorageValue('access_token');
|
||||
|
||||
return new Promise<Types.PostAssetSchema>((resolve, reject) => {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open('POST', `${this.baseUrl}/api/blog/admin/posts/${postId}/assets`);
|
||||
if (token) {
|
||||
request.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
|
||||
request.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable) return;
|
||||
onProgress?.(Math.round((event.loaded / event.total) * 100));
|
||||
};
|
||||
|
||||
request.onload = () => {
|
||||
let body: (Types.PostAssetSchema & ApiErrorBody) | null = null;
|
||||
try {
|
||||
body = request.responseText ? JSON.parse(request.responseText) as Types.PostAssetSchema & ApiErrorBody : null;
|
||||
} catch {
|
||||
body = null;
|
||||
}
|
||||
if (request.status >= 200 && request.status < 300 && body) {
|
||||
onProgress?.(100);
|
||||
resolve(body);
|
||||
return;
|
||||
}
|
||||
reject(new Error(body?.error || body?.detail || 'Asset upload failed'));
|
||||
};
|
||||
|
||||
request.onerror = () => reject(new Error('Asset upload failed'));
|
||||
request.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
async deleteBlogPostAsset(postId: number, assetId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/posts/${postId}/assets/${assetId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deletePost(slug: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}`, {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@@ -341,16 +690,43 @@ class ApiClient {
|
||||
|
||||
// Comments
|
||||
async getComments(slug: string) {
|
||||
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${slug}/comments`);
|
||||
return this.request<Types.CommentSchema[]>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`);
|
||||
}
|
||||
|
||||
async createComment(slug: string, data: Types.CommentCreateSchema) {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/posts/${slug}/comments`, {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/comments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateComment(commentId: number, data: Types.CommentUpdateSchema) {
|
||||
return this.request<Types.CommentSchema>(`/api/blog/comments/${commentId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async hideComment(commentId: number, note?: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/hide`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
});
|
||||
}
|
||||
|
||||
async unhideComment(commentId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/unhide`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(commentId: number, note?: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/comments/${commentId}/delete`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ note: note ?? '' }),
|
||||
});
|
||||
}
|
||||
|
||||
async listDeletedComments() {
|
||||
return this.request<Types.CommentSchema[]>('/api/blog/deleted/comments');
|
||||
}
|
||||
@@ -363,13 +739,27 @@ class ApiClient {
|
||||
|
||||
// Likes
|
||||
async toggleLike(slug: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/like`, {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/like`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async toggleSave(slug: string) {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/save`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async getBlogInteraction(slug: string) {
|
||||
return this.request<Types.BlogInteractionSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/interaction`);
|
||||
}
|
||||
|
||||
async getLikesCount(slug: string) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${slug}/likes`);
|
||||
return this.request<Types.MessageSchema>(`/api/blog/posts/${encodeURIComponent(slug)}/likes`);
|
||||
}
|
||||
|
||||
async getMyBlogActivity() {
|
||||
return this.request<Types.BlogProfileActivitySchema>('/api/blog/me/activity');
|
||||
}
|
||||
|
||||
// Categories
|
||||
@@ -377,6 +767,30 @@ class ApiClient {
|
||||
return this.request<Types.CategorySchema[]>('/api/blog/categories');
|
||||
}
|
||||
|
||||
async listAdminCategories() {
|
||||
return this.request<Types.AdminCategorySchema[]>('/api/blog/admin/categories');
|
||||
}
|
||||
|
||||
async createCategory(data: Types.CategoryWriteSchema) {
|
||||
return this.request<Types.AdminCategorySchema>('/api/blog/admin/categories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateCategory(categoryId: number, data: Types.CategoryWriteSchema) {
|
||||
return this.request<Types.AdminCategorySchema>(`/api/blog/admin/categories/${categoryId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCategory(categoryId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/categories/${categoryId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getCategory(slug: string) {
|
||||
return this.request<Types.CategorySchema>(`/api/blog/categories/${slug}`);
|
||||
}
|
||||
@@ -396,6 +810,30 @@ class ApiClient {
|
||||
return this.request<Types.TagSchema[]>('/api/blog/tags');
|
||||
}
|
||||
|
||||
async listAdminTags() {
|
||||
return this.request<Types.AdminTagSchema[]>('/api/blog/admin/tags');
|
||||
}
|
||||
|
||||
async createTag(data: Types.TagWriteSchema) {
|
||||
return this.request<Types.AdminTagSchema>('/api/blog/admin/tags', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateTag(tagId: number, data: Types.TagWriteSchema) {
|
||||
return this.request<Types.AdminTagSchema>(`/api/blog/admin/tags/${tagId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteTag(tagId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/blog/admin/tags/${tagId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async getTag(slug: string) {
|
||||
return this.request<Types.TagSchema>(`/api/blog/tags/${slug}`);
|
||||
}
|
||||
@@ -469,13 +907,75 @@ class ApiClient {
|
||||
}
|
||||
|
||||
async updateEvent(eventId: number, data: Types.EventUpdateSchema) {
|
||||
return this.request<Types.EventSchema>(`/api/events/${eventId}`, {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async createEvent(data: Types.EventCreateSchema) {
|
||||
return this.request<Types.EventDetailSchema>('/api/events/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadEventFeaturedImage(eventId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/featured-image`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(body.error || body.detail || 'Event image upload failed');
|
||||
}
|
||||
return response.json() as Promise<Types.EventDetailSchema>;
|
||||
}
|
||||
|
||||
async deleteEventFeaturedImage(eventId: number) {
|
||||
return this.request<Types.EventDetailSchema>(`/api/events/${eventId}/featured-image`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async listEventGallery(eventId: number) {
|
||||
return this.request<Types.EventGalleryItem[]>(`/api/events/${eventId}/gallery`);
|
||||
}
|
||||
|
||||
async uploadEventGalleryImage(eventId: number, file: File, data: { title?: string; alt_text?: string } = {}) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
if (data.title) formData.append('title', data.title);
|
||||
if (data.alt_text) formData.append('alt_text', data.alt_text);
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/events/${eventId}/gallery`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = (await response.json().catch(() => ({}))) as ApiErrorBody;
|
||||
throw new Error(body.error || body.detail || 'Event gallery upload failed');
|
||||
}
|
||||
return response.json() as Promise<Types.EventGalleryItem>;
|
||||
}
|
||||
|
||||
async deleteEventGalleryImage(eventId: number, imageId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}/gallery/${imageId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEvent(eventId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
@@ -591,6 +1091,44 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
async listDiscountCodes(params?: {
|
||||
search?: string;
|
||||
is_active?: boolean;
|
||||
type?: 'percent' | 'fixed';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.is_active != null) query.set('is_active', String(params.is_active));
|
||||
if (params?.type) query.set('type', params.type);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedDiscountCodeSchema>(
|
||||
`/api/payments/admin/discount-codes${query.toString() ? `?${query.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
async createDiscountCode(data: Types.DiscountCodeWriteSchema) {
|
||||
return this.request<Types.DiscountCodeSchema>('/api/payments/admin/discount-codes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateDiscountCode(codeId: number, data: Types.DiscountCodeWriteSchema) {
|
||||
return this.request<Types.DiscountCodeSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDiscountCode(codeId: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/payments/admin/discount-codes/${codeId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
// ============= Gallery Endpoints =============
|
||||
|
||||
async getGalleryImages(params?: {
|
||||
@@ -614,7 +1152,7 @@ class ApiClient {
|
||||
if (data.description) formData.append('description', data.description);
|
||||
if (data.tag_ids) formData.append('tag_ids', JSON.stringify(data.tag_ids));
|
||||
|
||||
const token = localStorage.getItem('access_token');
|
||||
const token = this.getStorageValue('access_token');
|
||||
const response = await fetch(`${this.baseUrl}/api/gallery/images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -639,12 +1177,86 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async getMajors(): Promise<Types.MajorOption[]> {
|
||||
return this.request('/api/meta/majors', { method: 'GET' });
|
||||
async getMajors(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||
const data = await this.getMajorsPaged({ limit: 100, offset: 0, ...params });
|
||||
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||
}
|
||||
|
||||
async getUniversities(): Promise<Types.MajorOption[]> {
|
||||
return this.request('/api/meta/universities', { method: 'GET' });
|
||||
async getUniversities(params?: { search?: string; limit?: number; offset?: number }): Promise<Types.MajorOption[]> {
|
||||
const data = await this.getUniversitiesPaged({ limit: 100, offset: 0, ...params });
|
||||
return data.results.map((item) => ({ code: item.code, label: item.label, id: item.id, name: item.name, user_count: item.user_count }));
|
||||
}
|
||||
|
||||
async getMajorsPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async getUniversitiesPaged(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async listAdminMajors(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/majors${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async createMajor(data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>('/api/meta/admin/majors', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateMajor(id: number, data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/majors/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteMajor(id: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/meta/admin/majors/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async listAdminUniversities(params?: { search?: string; limit?: number; offset?: number }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
return this.request<Types.PagedMetaOptionSchema>(`/api/meta/admin/universities${query.toString() ? `?${query.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async createUniversity(data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>('/api/meta/admin/universities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async updateUniversity(id: number, data: Types.MetaOptionWriteSchema) {
|
||||
return this.request<Types.MetaOptionSchema>(`/api/meta/admin/universities/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteUniversity(id: number) {
|
||||
return this.request<Types.MessageSchema>(`/api/meta/admin/universities/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async subscribeNewsletter(email: string) {
|
||||
@@ -657,6 +1269,47 @@ class ApiClient {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async getNotifications(params?: { limit?: number; offset?: number; type?: string }) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.limit != null) query.set('limit', String(params.limit));
|
||||
if (params?.offset != null) query.set('offset', String(params.offset));
|
||||
if (params?.type) query.set('type', params.type);
|
||||
return this.request<Types.NotificationListSchema>(
|
||||
`/api/notifications/${query.toString() ? `?${query.toString()}` : ''}`
|
||||
);
|
||||
}
|
||||
|
||||
async markNotificationSeen(id: string) {
|
||||
return this.request<Types.NotificationSeenResponseSchema>('/api/notifications/mark-seen', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
}
|
||||
|
||||
async deleteNotification(id: string) {
|
||||
return this.request<Types.NotificationDeleteResponseSchema>(`/api/notifications/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async markAllNotificationsRead(type?: string) {
|
||||
const query = type ? `?type=${encodeURIComponent(type)}` : '';
|
||||
return this.request<{ marked_read: number }>(`/api/notifications/mark-all-read${query}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
async issueNotificationStreamToken() {
|
||||
return this.request<Types.NotificationStreamTokenResponseSchema>('/api/notifications/stream-token', {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
buildNotificationStreamUrl(token: string) {
|
||||
const cleanBaseUrl = this.baseUrl.replace(/\/+$/, '');
|
||||
return `${cleanBaseUrl}/api/notifications/stream/?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_BASE_URL);
|
||||
|
||||
17
src/lib/blog-routes.ts
Normal file
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 {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
export interface MajorOption {
|
||||
code: string;
|
||||
label: string;
|
||||
id?: number;
|
||||
name?: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface MetaOptionSchema {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
label: string;
|
||||
user_count?: number;
|
||||
}
|
||||
|
||||
export interface PagedMetaOptionSchema {
|
||||
count: number;
|
||||
results: MetaOptionSchema[];
|
||||
}
|
||||
|
||||
export interface MetaOptionWriteSchema {
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface UserProfileSchema {
|
||||
id: number;
|
||||
email: string;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
bio?: string;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number;
|
||||
@@ -34,70 +59,220 @@ export interface UserProfileSchema {
|
||||
date_joined: string;
|
||||
|
||||
is_email_verified?: boolean;
|
||||
is_mobile_verified?: boolean;
|
||||
requires_mobile_verification?: boolean;
|
||||
has_google_link?: boolean;
|
||||
is_active?: boolean;
|
||||
is_staff?: boolean;
|
||||
is_superuser?: boolean;
|
||||
is_committee?: boolean;
|
||||
is_deleted?: boolean;
|
||||
deleted_at?: string | null;
|
||||
can_access_blog_admin?: boolean;
|
||||
can_write_blog_posts?: boolean;
|
||||
can_review_blog_posts?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListSchema {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
full_name?: string | null;
|
||||
university?: string | null;
|
||||
major?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
bio?: string | null;
|
||||
is_email_verified?: boolean;
|
||||
is_mobile_verified?: boolean;
|
||||
is_deleted?: boolean;
|
||||
deleted_at?: string | null;
|
||||
can_access_blog_admin?: boolean;
|
||||
can_write_blog_posts?: boolean;
|
||||
can_review_blog_posts?: boolean;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
date_joined: string;
|
||||
}
|
||||
|
||||
export interface UserRegistrationSchema {
|
||||
email: string;
|
||||
password: string;
|
||||
export interface AuthorizationRoleSchema {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
locked: boolean;
|
||||
}
|
||||
|
||||
export interface UserAuthorizationSchema {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
mobile?: string | null;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
student_id: string;
|
||||
year_of_study: number;
|
||||
major: string;
|
||||
university: string;
|
||||
is_active: boolean;
|
||||
is_staff: boolean;
|
||||
is_superuser: boolean;
|
||||
groups: string[];
|
||||
roles: AuthorizationRoleSchema[];
|
||||
}
|
||||
|
||||
export interface UserAuthorizationUpdateSchema {
|
||||
is_staff: boolean;
|
||||
groups: string[];
|
||||
}
|
||||
|
||||
export interface UserRegistrationSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
password: string;
|
||||
username: string;
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
}
|
||||
|
||||
export type UserUpdateSchema = {
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
bio?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
student_id?: number | null;
|
||||
student_id?: string | null;
|
||||
};
|
||||
|
||||
|
||||
export interface UserLoginSchema {
|
||||
email: string;
|
||||
identifier: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface UserOtpLoginSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface RegisterOtpVerifySchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface OtpSendSchema {
|
||||
mobile: string;
|
||||
mode: "register" | "login" | "reset_password" | "verify_mobile" | "google_claim";
|
||||
}
|
||||
|
||||
export interface OtpSendResponseSchema {
|
||||
message: string;
|
||||
expires_in_seconds: number;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
export interface TokenRefreshIn {
|
||||
refresh_token: string;
|
||||
}
|
||||
|
||||
export interface UsernameCheckSchema {
|
||||
available: boolean;
|
||||
exists: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequestSchema {
|
||||
email: string;
|
||||
export interface MobileLookupSchema {
|
||||
exists: boolean;
|
||||
has_password: boolean;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmSchema {
|
||||
export interface PasswordResetSchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface MobileOtpSendSchema {
|
||||
mobile: string;
|
||||
}
|
||||
|
||||
export interface MobileOtpVerifySchema {
|
||||
mobile: string;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface GoogleFlowResponseSchema {
|
||||
status: "authenticated" | "collect_profile" | "claim_required" | "error";
|
||||
email?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
avatar_url?: string | null;
|
||||
resolution?: "new_account" | "existing_email_claim" | "existing_mobile_claim" | null;
|
||||
mobile?: string | null;
|
||||
mobile_hint?: string | null;
|
||||
detail?: string | null;
|
||||
access_token?: string | null;
|
||||
refresh_token?: string | null;
|
||||
}
|
||||
|
||||
export interface GoogleCompleteSchema {
|
||||
flow: string;
|
||||
mobile: string;
|
||||
username?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
major?: string | null;
|
||||
university?: string | null;
|
||||
first_name?: string | null;
|
||||
last_name?: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationSchema {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
message: string;
|
||||
level: "info" | "success" | "warning" | "error";
|
||||
created_at: string;
|
||||
is_seen: boolean;
|
||||
delete_on_seen: boolean;
|
||||
action_url?: string | null;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | number | null;
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface NotificationListSchema {
|
||||
count: number;
|
||||
unread_count: number;
|
||||
notifications: NotificationSchema[];
|
||||
}
|
||||
|
||||
export interface NotificationSeenResponseSchema {
|
||||
marked_read: boolean;
|
||||
notification_id?: string | null;
|
||||
deleted?: boolean;
|
||||
notification?: NotificationSchema | null;
|
||||
unread_count?: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationDeleteResponseSchema {
|
||||
deleted: boolean;
|
||||
notification_id?: string | null;
|
||||
unread_count?: number | null;
|
||||
}
|
||||
|
||||
export interface NotificationStreamTokenResponseSchema {
|
||||
token: string;
|
||||
password: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
// Blog Types
|
||||
@@ -107,19 +282,41 @@ export interface PostListSchema {
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
featured_image?: string;
|
||||
absolute_featured_image_url?: string | null;
|
||||
absolute_featured_image_thumbnail_url?: string | null;
|
||||
absolute_featured_image_preview_url?: string | null;
|
||||
author: {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
};
|
||||
category?: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
};
|
||||
category_path?: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
}>;
|
||||
writers?: Array<{
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
}>;
|
||||
tags: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -130,23 +327,72 @@ export interface PostListSchema {
|
||||
created_at: string;
|
||||
is_featured: boolean;
|
||||
reading_time?: number;
|
||||
updated_at: string;
|
||||
seo_title?: string;
|
||||
seo_description?: string;
|
||||
canonical_url?: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
noindex?: boolean;
|
||||
focus_keyword?: string;
|
||||
likes_count?: number;
|
||||
saves_count?: number;
|
||||
comments_count?: number;
|
||||
}
|
||||
|
||||
export interface PostDetailSchema extends PostListSchema {
|
||||
content: string;
|
||||
updated_at: string;
|
||||
content_html?: string;
|
||||
review_note?: string;
|
||||
og_image_url?: string | null;
|
||||
views_count?: number;
|
||||
assets?: PostAssetSchema[];
|
||||
}
|
||||
|
||||
export interface PostCreateSchema {
|
||||
title: string;
|
||||
content: string;
|
||||
summary: string;
|
||||
category_id?: number;
|
||||
excerpt?: string;
|
||||
category_id?: number | null;
|
||||
tag_ids?: number[];
|
||||
featured_image?: string;
|
||||
writer_ids?: number[];
|
||||
is_featured?: boolean;
|
||||
status?: 'draft' | 'published';
|
||||
status?: 'draft' | 'submitted' | 'changes_requested' | 'published' | 'archived';
|
||||
seo_title?: string;
|
||||
seo_description?: string;
|
||||
canonical_url?: string;
|
||||
og_title?: string;
|
||||
og_description?: string;
|
||||
noindex?: boolean;
|
||||
focus_keyword?: string;
|
||||
}
|
||||
|
||||
export interface PostReviewSchema {
|
||||
action: 'publish' | 'approve' | 'request_changes' | 'changes_requested' | 'archive';
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PostAssetSchema {
|
||||
id: number;
|
||||
file_type: 'image' | 'video' | 'document' | 'archive' | 'other';
|
||||
title: string;
|
||||
alt_text?: string;
|
||||
caption?: string;
|
||||
size: number;
|
||||
mime_type?: string;
|
||||
created_at: string;
|
||||
absolute_file_url?: string | null;
|
||||
absolute_thumbnail_url?: string | null;
|
||||
absolute_preview_url?: string | null;
|
||||
absolute_blur_url?: string | null;
|
||||
markdown_image?: string | null;
|
||||
markdown_link?: string | null;
|
||||
uploaded_by: {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommentSchema {
|
||||
@@ -157,13 +403,24 @@ export interface CommentSchema {
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
bio?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
};
|
||||
post_id: number;
|
||||
post_title: string;
|
||||
post_slug: string;
|
||||
parent_id?: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
is_approved: boolean;
|
||||
is_hidden?: boolean;
|
||||
is_deleted?: boolean;
|
||||
hidden_at?: string | null;
|
||||
deleted_at?: string | null;
|
||||
hidden_replies_count?: number;
|
||||
replies?: CommentSchema[];
|
||||
}
|
||||
|
||||
export interface CommentCreateSchema {
|
||||
@@ -171,14 +428,54 @@ export interface CommentCreateSchema {
|
||||
parent_id?: number;
|
||||
}
|
||||
|
||||
export interface CommentUpdateSchema {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface BlogBannerSchema {
|
||||
id: number;
|
||||
title?: string;
|
||||
alt_text?: string;
|
||||
image_url: string;
|
||||
url: string;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface BlogInteractionSchema {
|
||||
liked: boolean;
|
||||
saved: boolean;
|
||||
likes_count: number;
|
||||
saves_count: number;
|
||||
comments_count: number;
|
||||
}
|
||||
|
||||
export interface BlogProfileActivitySchema {
|
||||
liked_posts: PostListSchema[];
|
||||
saved_posts: PostListSchema[];
|
||||
comments: CommentSchema[];
|
||||
replies: CommentSchema[];
|
||||
}
|
||||
|
||||
export interface CategorySchema {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
description?: string;
|
||||
parent_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminCategorySchema extends CategorySchema {
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface CategoryWriteSchema {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
description?: string | null;
|
||||
parent_id?: number | null;
|
||||
}
|
||||
|
||||
export interface TagSchema {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -186,6 +483,45 @@ export interface TagSchema {
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AdminTagSchema extends TagSchema {
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface TagWriteSchema {
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
}
|
||||
|
||||
export interface BlogFilterCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
parent_id?: number | null;
|
||||
post_count: number;
|
||||
children: BlogFilterCategory[];
|
||||
}
|
||||
|
||||
export interface BlogFilterTag {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface BlogFilterAuthor {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
post_count: number;
|
||||
}
|
||||
|
||||
export interface BlogFiltersSchema {
|
||||
categories: BlogFilterCategory[];
|
||||
tags: BlogFilterTag[];
|
||||
authors: BlogFilterAuthor[];
|
||||
}
|
||||
|
||||
// Events Types
|
||||
export interface EventListItemSchema {
|
||||
id: number;
|
||||
@@ -194,6 +530,8 @@ export interface EventListItemSchema {
|
||||
description: string;
|
||||
featured_image?: string | null;
|
||||
absolute_featured_image_url?: string | null;
|
||||
absolute_featured_image_thumbnail_url?: string | null;
|
||||
absolute_featured_image_preview_url?: string | null;
|
||||
event_type: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
location?: string | null;
|
||||
@@ -205,6 +543,8 @@ export interface EventListItemSchema {
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
status_label?: string;
|
||||
event_type_label?: string;
|
||||
registration_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -214,8 +554,16 @@ export interface EventGalleryItem {
|
||||
title: string;
|
||||
description: string;
|
||||
absolute_image_url?: string | null;
|
||||
absolute_image_preview_url?: string | null;
|
||||
absolute_image_blur_url?: string | null;
|
||||
width?: number;
|
||||
height?: number;
|
||||
file_size_mb?: number;
|
||||
markdown_url?: string;
|
||||
image?: string;
|
||||
alt_text?: string | null;
|
||||
is_public?: boolean;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface EventDetailSchema extends EventListItemSchema {
|
||||
@@ -228,19 +576,28 @@ export interface EventDetailSchema extends EventListItemSchema {
|
||||
export interface EventCreateSchema {
|
||||
title: string;
|
||||
description: string;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
location: string;
|
||||
capacity?: number;
|
||||
event_image?: string;
|
||||
requirements?: string;
|
||||
is_registration_open?: boolean;
|
||||
slug?: string | null;
|
||||
event_type: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
location?: string | null;
|
||||
online_link?: string | null;
|
||||
start_time: string;
|
||||
end_time: string;
|
||||
registration_start_date?: string | null;
|
||||
registration_end_date?: string | null;
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
registration_success_markdown?: string | null;
|
||||
gallery_image_ids?: number[] | null;
|
||||
}
|
||||
|
||||
export interface PaymentAdminSchema {
|
||||
id: number;
|
||||
authority?: string | null;
|
||||
ref_id?: string | null;
|
||||
card_pan?: string | null;
|
||||
card_hash?: string | null;
|
||||
status: number;
|
||||
status_label: string;
|
||||
base_amount: number;
|
||||
@@ -265,6 +622,14 @@ export interface RegistrationAdminSchema {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
mobile?: string | null;
|
||||
profile_picture?: string | null;
|
||||
profile_picture_thumbnail_url?: string | null;
|
||||
profile_picture_preview_url?: string | null;
|
||||
university?: string | null;
|
||||
major?: string | null;
|
||||
student_id?: string | null;
|
||||
year_of_study?: number | null;
|
||||
};
|
||||
payments: PaymentAdminSchema[];
|
||||
}
|
||||
@@ -274,6 +639,7 @@ export interface EventAdminDetailSchema extends EventDetailSchema {
|
||||
}
|
||||
export interface EventUpdateSchema {
|
||||
title?: string;
|
||||
slug?: string | null;
|
||||
description?: string;
|
||||
event_type?: 'online' | 'on_site' | 'hybrid';
|
||||
address?: string | null;
|
||||
@@ -286,9 +652,47 @@ export interface EventUpdateSchema {
|
||||
capacity?: number | null;
|
||||
price?: number | null;
|
||||
status?: 'draft' | 'published' | 'cancelled' | 'completed';
|
||||
registration_success_markdown?: string | null;
|
||||
gallery_image_ids?: number[] | null;
|
||||
}
|
||||
|
||||
export interface DiscountCodeSchema {
|
||||
id: number;
|
||||
code: string;
|
||||
type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
max_discount?: number | null;
|
||||
is_active: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
usage_limit_total?: number | null;
|
||||
usage_limit_per_user?: number | null;
|
||||
min_amount?: number | null;
|
||||
applicable_event_ids: number[];
|
||||
usage_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PagedDiscountCodeSchema {
|
||||
count: number;
|
||||
results: DiscountCodeSchema[];
|
||||
}
|
||||
|
||||
export interface DiscountCodeWriteSchema {
|
||||
code: string;
|
||||
type: 'percent' | 'fixed';
|
||||
value: number;
|
||||
max_discount?: number | null;
|
||||
is_active?: boolean;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
usage_limit_total?: number | null;
|
||||
usage_limit_per_user?: number | null;
|
||||
min_amount?: number | null;
|
||||
applicable_event_ids?: number[];
|
||||
}
|
||||
|
||||
export interface EventRegistrationSchema {
|
||||
id: number;
|
||||
status: 'pending' | 'confirmed' | 'cancelled' | 'attended';
|
||||
@@ -322,6 +726,11 @@ export interface GalleryImageSchema {
|
||||
title: string;
|
||||
description?: string;
|
||||
image: string;
|
||||
absolute_image_url?: string | null;
|
||||
absolute_image_preview_url?: string | null;
|
||||
absolute_image_blur_url?: string | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
uploaded_by: {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -344,6 +753,197 @@ export interface PaginatedResponse<T> {
|
||||
previous?: string;
|
||||
}
|
||||
|
||||
// Admin analytics
|
||||
export interface AnalyticsPointSchema {
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPointGroupSchema {
|
||||
items: AnalyticsPointSchema[];
|
||||
top_items: AnalyticsPointSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTrendPointSchema {
|
||||
date: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsRegistrationStatusSchema {
|
||||
status: string;
|
||||
label: string;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopEventSchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
attendees: number;
|
||||
capacity?: number | null;
|
||||
fill_rate?: number | null;
|
||||
revenue: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularitySchema {
|
||||
id: number;
|
||||
title: string;
|
||||
slug: string;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsPostPopularityGroupSchema {
|
||||
items: AnalyticsPostPopularitySchema[];
|
||||
top_items: AnalyticsPostPopularitySchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
}
|
||||
|
||||
export interface AnalyticsTopPostSchema extends AnalyticsPostPopularitySchema {
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboardAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
};
|
||||
users: {
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
by_year: AnalyticsPointSchema[];
|
||||
};
|
||||
events: {
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
by_major: AnalyticsPointSchema[];
|
||||
by_university: AnalyticsPointSchema[];
|
||||
top_events: AnalyticsTopEventSchema[];
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
};
|
||||
revenue: {
|
||||
trend: AnalyticsTrendPointSchema[];
|
||||
by_event: AnalyticsPointSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
total_paid: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
};
|
||||
blog: {
|
||||
totals: {
|
||||
posts: number;
|
||||
likes: number;
|
||||
saves: number;
|
||||
comments: number;
|
||||
};
|
||||
post_popularity: AnalyticsPostPopularitySchema[];
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
by_category: AnalyticsPointSchema[];
|
||||
by_tag: AnalyticsPointSchema[];
|
||||
};
|
||||
achievements: {
|
||||
distinct_participants: number;
|
||||
learning_hours: number;
|
||||
published_content: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AnalyticsEventOptionsSchema {
|
||||
count: number;
|
||||
results: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface UserAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
granularity: 'day' | 'week' | 'month';
|
||||
};
|
||||
summary: {
|
||||
total_users: number;
|
||||
verified_users: number;
|
||||
unverified_users: number;
|
||||
profile_completion_rate: number;
|
||||
};
|
||||
signup_trend: AnalyticsTrendPointSchema[];
|
||||
by_major: AnalyticsPointGroupSchema;
|
||||
by_university: AnalyticsPointGroupSchema;
|
||||
by_year: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
export interface EventAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
event_id?: number | null;
|
||||
};
|
||||
summary: {
|
||||
total_events: number;
|
||||
total_registrations: number;
|
||||
distinct_participants: number;
|
||||
total_revenue: number;
|
||||
total_discount: number;
|
||||
total_base: number;
|
||||
learning_hours: number;
|
||||
};
|
||||
registration_status: AnalyticsRegistrationStatusSchema[];
|
||||
payment_status: AnalyticsRegistrationStatusSchema[];
|
||||
attendee_by_major: AnalyticsPointGroupSchema;
|
||||
attendee_by_university: AnalyticsPointGroupSchema;
|
||||
registration_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_trend: AnalyticsTrendPointSchema[];
|
||||
revenue_by_event: AnalyticsPointGroupSchema;
|
||||
top_events: {
|
||||
top_items: AnalyticsTopEventSchema[];
|
||||
other_count: number;
|
||||
total_count: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface BlogAnalyticsSchema {
|
||||
filters: {
|
||||
date_from?: string | null;
|
||||
date_to?: string | null;
|
||||
};
|
||||
summary: {
|
||||
published_posts: number;
|
||||
total_likes: number;
|
||||
total_saves: number;
|
||||
total_comments: number;
|
||||
community_engagement: number;
|
||||
};
|
||||
activity_trend: Array<{ date: string; likes: number; saves: number; comments: number }>;
|
||||
post_popularity: AnalyticsPostPopularityGroupSchema;
|
||||
top_posts: AnalyticsTopPostSchema[];
|
||||
by_category: AnalyticsPointGroupSchema;
|
||||
by_tag: AnalyticsPointGroupSchema;
|
||||
}
|
||||
|
||||
// payment
|
||||
export interface CreatePaymentOut {
|
||||
start_pay_url: string;
|
||||
|
||||
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 const getThumbUrl = (e: Types.EventListItemSchema) =>
|
||||
e.absolute_featured_image_url ||
|
||||
e.featured_image ||
|
||||
DEFAULT_THUMB;
|
||||
export function formatJalaliDate(iso?: string): string {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const date = new Date(iso);
|
||||
const locale = Intl.DateTimeFormat.supportedLocalesOf(['fa-IR-u-ca-persian']).length > 0
|
||||
? 'fa-IR-u-ca-persian'
|
||||
: 'fa-IR';
|
||||
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(date);
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_THUMB = "/placeholder.svg";
|
||||
|
||||
const pickFirstUrl = (...values: Array<string | null | undefined>) =>
|
||||
values.find((value) => Boolean(value)) || DEFAULT_THUMB;
|
||||
|
||||
export const getEventCardImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getEventHeroImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_url,
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getEventSeoImageUrl = (event: Types.EventListItemSchema) =>
|
||||
pickFirstUrl(
|
||||
event.absolute_featured_image_url,
|
||||
event.absolute_featured_image_preview_url,
|
||||
event.absolute_featured_image_thumbnail_url,
|
||||
event.featured_image,
|
||||
);
|
||||
|
||||
export const getThumbUrl = getEventCardImageUrl;
|
||||
|
||||
export const getBlogCardImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getBlogHeroImageUrl = (post: Types.PostListSchema) =>
|
||||
[
|
||||
post.absolute_featured_image_preview_url,
|
||||
post.absolute_featured_image_url,
|
||||
post.absolute_featured_image_thumbnail_url,
|
||||
post.featured_image,
|
||||
].find((value) => Boolean(value));
|
||||
|
||||
export const getGalleryImagePreviewUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
|
||||
export const getGalleryImageBlurUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_blur_url,
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
|
||||
export const getGalleryImageFullUrl = (
|
||||
image:
|
||||
| Types.EventGalleryItem
|
||||
| Types.GalleryImageSchema,
|
||||
) =>
|
||||
pickFirstUrl(
|
||||
image.absolute_image_url,
|
||||
image.absolute_image_preview_url,
|
||||
image.absolute_image_blur_url,
|
||||
"image" in image ? image.image : undefined,
|
||||
);
|
||||
|
||||
const PERSIAN_DIGITS = ['۰','۱','۲','۳','۴','۵','۶','۷','۸','۹'];
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user