From 722a520e68a02dfdef23dcae0a598c5ed2182667 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Fri, 24 Jul 2026 17:16:53 +0330 Subject: [PATCH] Initial commit: NovinTrades website monorepo. Co-authored-by: Cursor --- .env.example | 14 + .gitignore | 30 + README.md | 42 + apps/admin/.oxlintrc.json | 8 + apps/admin/index.html | 19 + apps/admin/package.json | 33 + apps/admin/public/favicon.svg | 5 + apps/admin/src/App.tsx | 37 + apps/admin/src/assets/hero.png | Bin 0 -> 13057 bytes apps/admin/src/components/ContactFields.css | 38 + apps/admin/src/components/ContactFields.tsx | 105 + apps/admin/src/components/DataTable.css | 152 + apps/admin/src/components/DataTable.tsx | 42 + apps/admin/src/components/IconButton.css | 67 + apps/admin/src/components/IconButton.tsx | 33 + apps/admin/src/components/ImageCropper.css | 111 + apps/admin/src/components/ImageCropper.tsx | 206 + apps/admin/src/components/ImageGallery.css | 64 + apps/admin/src/components/ImageGallery.tsx | 99 + apps/admin/src/components/Layout.css | 66 + apps/admin/src/components/Layout.tsx | 43 + apps/admin/src/components/ListToolbar.css | 7 + apps/admin/src/components/ListToolbar.tsx | 85 + apps/admin/src/components/MultiSelect.css | 217 + apps/admin/src/components/MultiSelect.tsx | 193 + apps/admin/src/components/PageHeader.css | 81 + apps/admin/src/components/PageHeader.tsx | 31 + apps/admin/src/components/Pagination.css | 27 + apps/admin/src/components/Pagination.tsx | 54 + apps/admin/src/components/RequireAuth.tsx | 10 + apps/admin/src/components/RichTextEditor.css | 177 + apps/admin/src/components/RichTextEditor.tsx | 182 + apps/admin/src/components/RowActions.tsx | 20 + apps/admin/src/components/Sidebar.css | 146 + apps/admin/src/components/Sidebar.tsx | 61 + apps/admin/src/components/StatusBadge.tsx | 27 + apps/admin/src/components/StatusModal.css | 141 + apps/admin/src/components/StatusModal.tsx | 114 + apps/admin/src/components/TagInput.css | 52 + apps/admin/src/components/TagInput.tsx | 69 + .../admin/src/components/TitlePreviewCell.css | 65 + .../admin/src/components/TitlePreviewCell.tsx | 31 + apps/admin/src/data/options.ts | 12 + apps/admin/src/index.css | 160 + apps/admin/src/lib/api.ts | 42 + apps/admin/src/lib/auth.ts | 35 + apps/admin/src/lib/categories.ts | 36 + apps/admin/src/lib/config.ts | 3 + apps/admin/src/lib/format.ts | 31 + apps/admin/src/lib/image.ts | 27 + apps/admin/src/lib/list.ts | 44 + apps/admin/src/lib/services.ts | 202 + apps/admin/src/lib/types.ts | 89 + apps/admin/src/main.tsx | 10 + apps/admin/src/pages/BlogNewPage.tsx | 219 + apps/admin/src/pages/BlogPage.tsx | 226 + apps/admin/src/pages/BrandNewPage.tsx | 324 ++ apps/admin/src/pages/BrandsPage.tsx | 241 + apps/admin/src/pages/HomePage.tsx | 124 + apps/admin/src/pages/LoginPage.css | 79 + apps/admin/src/pages/LoginPage.tsx | 101 + apps/admin/src/pages/ReportageNewPage.tsx | 242 + apps/admin/src/pages/ReportagePage.tsx | 243 + apps/admin/src/styles/forms.css | 175 + apps/admin/tsconfig.app.json | 26 + apps/admin/tsconfig.json | 7 + apps/admin/tsconfig.node.json | 23 + apps/admin/vite.config.ts | 22 + apps/api/package.json | 36 + apps/api/prisma/data/brand-categories.ts | 169 + .../20260723125816_init/migration.sql | 193 + .../migration.sql | 2 + .../20260724100000_brand_city/migration.sql | 2 + .../migration.sql | 80 + .../api/prisma/migrations/migration_lock.toml | 3 + apps/api/prisma/schema.prisma | 143 + apps/api/prisma/seed.ts | 84 + apps/api/src/app.ts | 42 + apps/api/src/index.ts | 13 + apps/api/src/lib/limits.ts | 13 + apps/api/src/lib/prisma.ts | 3 + apps/api/src/lib/s3.ts | 87 + apps/api/src/lib/slug.ts | 9 + apps/api/src/routes/auth.ts | 38 + apps/api/src/routes/blogs.ts | 131 + apps/api/src/routes/brands.ts | 178 + apps/api/src/routes/categories.ts | 44 + apps/api/src/routes/health.ts | 8 + apps/api/src/routes/reportages.ts | 136 + apps/api/src/routes/uploads.ts | 52 + apps/api/tsconfig.json | 16 + apps/web/README.md | 30 + apps/web/index.html | 23 + apps/web/package.json | 27 + apps/web/public/favicon.svg | 5 + apps/web/src/App.css | 9 + apps/web/src/App.tsx | 33 + apps/web/src/components/AboutUs.css | 77 + apps/web/src/components/AboutUs.tsx | 40 + apps/web/src/components/Categories.css | 102 + apps/web/src/components/Categories.tsx | 110 + apps/web/src/components/CategoryFilter.css | 39 + apps/web/src/components/CategoryFilter.tsx | 42 + apps/web/src/components/ContentCard.css | 113 + apps/web/src/components/ContentCard.tsx | 50 + apps/web/src/components/Footer.css | 113 + apps/web/src/components/Footer.tsx | 69 + apps/web/src/components/Header.css | 294 ++ apps/web/src/components/Header.tsx | 190 + apps/web/src/components/LatestBlog.css | 134 + apps/web/src/components/LatestBlog.tsx | 86 + apps/web/src/components/LatestBrands.css | 140 + apps/web/src/components/LatestBrands.tsx | 79 + apps/web/src/components/Layout.tsx | 28 + apps/web/src/components/PageHero.css | 78 + apps/web/src/components/PageHero.tsx | 36 + apps/web/src/components/Pagination.css | 35 + apps/web/src/components/Pagination.tsx | 49 + apps/web/src/components/RichHtml.tsx | 14 + apps/web/src/components/Slider.css | 215 + apps/web/src/components/Slider.tsx | 96 + apps/web/src/data.ts | 41 + apps/web/src/index.css | 112 + apps/web/src/lib/api.ts | 39 + apps/web/src/lib/format.ts | 72 + apps/web/src/lib/services.ts | 72 + apps/web/src/lib/types.ts | 72 + apps/web/src/main.tsx | 10 + apps/web/src/pages/AboutPage.css | 183 + apps/web/src/pages/AboutPage.tsx | 125 + apps/web/src/pages/BlogDetailPage.tsx | 141 + apps/web/src/pages/BlogListPage.tsx | 86 + apps/web/src/pages/BrandDetailPage.tsx | 127 + apps/web/src/pages/BrandListPage.tsx | 87 + apps/web/src/pages/ContactPage.css | 141 + apps/web/src/pages/ContactPage.tsx | 131 + apps/web/src/pages/HomePage.tsx | 17 + apps/web/src/pages/ReportageDetailPage.tsx | 117 + apps/web/src/pages/ReportageListPage.tsx | 88 + apps/web/src/pages/detail.css | 134 + apps/web/tsconfig.app.json | 26 + apps/web/tsconfig.json | 7 + apps/web/tsconfig.node.json | 23 + apps/web/vite.config.ts | 22 + docker-compose.yml | 21 + package-lock.json | 4252 +++++++++++++++++ package.json | 17 + 147 files changed, 15748 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/admin/.oxlintrc.json create mode 100644 apps/admin/index.html create mode 100644 apps/admin/package.json create mode 100644 apps/admin/public/favicon.svg create mode 100644 apps/admin/src/App.tsx create mode 100644 apps/admin/src/assets/hero.png create mode 100644 apps/admin/src/components/ContactFields.css create mode 100644 apps/admin/src/components/ContactFields.tsx create mode 100644 apps/admin/src/components/DataTable.css create mode 100644 apps/admin/src/components/DataTable.tsx create mode 100644 apps/admin/src/components/IconButton.css create mode 100644 apps/admin/src/components/IconButton.tsx create mode 100644 apps/admin/src/components/ImageCropper.css create mode 100644 apps/admin/src/components/ImageCropper.tsx create mode 100644 apps/admin/src/components/ImageGallery.css create mode 100644 apps/admin/src/components/ImageGallery.tsx create mode 100644 apps/admin/src/components/Layout.css create mode 100644 apps/admin/src/components/Layout.tsx create mode 100644 apps/admin/src/components/ListToolbar.css create mode 100644 apps/admin/src/components/ListToolbar.tsx create mode 100644 apps/admin/src/components/MultiSelect.css create mode 100644 apps/admin/src/components/MultiSelect.tsx create mode 100644 apps/admin/src/components/PageHeader.css create mode 100644 apps/admin/src/components/PageHeader.tsx create mode 100644 apps/admin/src/components/Pagination.css create mode 100644 apps/admin/src/components/Pagination.tsx create mode 100644 apps/admin/src/components/RequireAuth.tsx create mode 100644 apps/admin/src/components/RichTextEditor.css create mode 100644 apps/admin/src/components/RichTextEditor.tsx create mode 100644 apps/admin/src/components/RowActions.tsx create mode 100644 apps/admin/src/components/Sidebar.css create mode 100644 apps/admin/src/components/Sidebar.tsx create mode 100644 apps/admin/src/components/StatusBadge.tsx create mode 100644 apps/admin/src/components/StatusModal.css create mode 100644 apps/admin/src/components/StatusModal.tsx create mode 100644 apps/admin/src/components/TagInput.css create mode 100644 apps/admin/src/components/TagInput.tsx create mode 100644 apps/admin/src/components/TitlePreviewCell.css create mode 100644 apps/admin/src/components/TitlePreviewCell.tsx create mode 100644 apps/admin/src/data/options.ts create mode 100644 apps/admin/src/index.css create mode 100644 apps/admin/src/lib/api.ts create mode 100644 apps/admin/src/lib/auth.ts create mode 100644 apps/admin/src/lib/categories.ts create mode 100644 apps/admin/src/lib/config.ts create mode 100644 apps/admin/src/lib/format.ts create mode 100644 apps/admin/src/lib/image.ts create mode 100644 apps/admin/src/lib/list.ts create mode 100644 apps/admin/src/lib/services.ts create mode 100644 apps/admin/src/lib/types.ts create mode 100644 apps/admin/src/main.tsx create mode 100644 apps/admin/src/pages/BlogNewPage.tsx create mode 100644 apps/admin/src/pages/BlogPage.tsx create mode 100644 apps/admin/src/pages/BrandNewPage.tsx create mode 100644 apps/admin/src/pages/BrandsPage.tsx create mode 100644 apps/admin/src/pages/HomePage.tsx create mode 100644 apps/admin/src/pages/LoginPage.css create mode 100644 apps/admin/src/pages/LoginPage.tsx create mode 100644 apps/admin/src/pages/ReportageNewPage.tsx create mode 100644 apps/admin/src/pages/ReportagePage.tsx create mode 100644 apps/admin/src/styles/forms.css create mode 100644 apps/admin/tsconfig.app.json create mode 100644 apps/admin/tsconfig.json create mode 100644 apps/admin/tsconfig.node.json create mode 100644 apps/admin/vite.config.ts create mode 100644 apps/api/package.json create mode 100644 apps/api/prisma/data/brand-categories.ts create mode 100644 apps/api/prisma/migrations/20260723125816_init/migration.sql create mode 100644 apps/api/prisma/migrations/20260723132248_brand_gallery_urls/migration.sql create mode 100644 apps/api/prisma/migrations/20260724100000_brand_city/migration.sql create mode 100644 apps/api/prisma/migrations/20260724110000_shared_categories/migration.sql create mode 100644 apps/api/prisma/migrations/migration_lock.toml create mode 100644 apps/api/prisma/schema.prisma create mode 100644 apps/api/prisma/seed.ts create mode 100644 apps/api/src/app.ts create mode 100644 apps/api/src/index.ts create mode 100644 apps/api/src/lib/limits.ts create mode 100644 apps/api/src/lib/prisma.ts create mode 100644 apps/api/src/lib/s3.ts create mode 100644 apps/api/src/lib/slug.ts create mode 100644 apps/api/src/routes/auth.ts create mode 100644 apps/api/src/routes/blogs.ts create mode 100644 apps/api/src/routes/brands.ts create mode 100644 apps/api/src/routes/categories.ts create mode 100644 apps/api/src/routes/health.ts create mode 100644 apps/api/src/routes/reportages.ts create mode 100644 apps/api/src/routes/uploads.ts create mode 100644 apps/api/tsconfig.json create mode 100644 apps/web/README.md create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/App.css create mode 100644 apps/web/src/App.tsx create mode 100644 apps/web/src/components/AboutUs.css create mode 100644 apps/web/src/components/AboutUs.tsx create mode 100644 apps/web/src/components/Categories.css create mode 100644 apps/web/src/components/Categories.tsx create mode 100644 apps/web/src/components/CategoryFilter.css create mode 100644 apps/web/src/components/CategoryFilter.tsx create mode 100644 apps/web/src/components/ContentCard.css create mode 100644 apps/web/src/components/ContentCard.tsx create mode 100644 apps/web/src/components/Footer.css create mode 100644 apps/web/src/components/Footer.tsx create mode 100644 apps/web/src/components/Header.css create mode 100644 apps/web/src/components/Header.tsx create mode 100644 apps/web/src/components/LatestBlog.css create mode 100644 apps/web/src/components/LatestBlog.tsx create mode 100644 apps/web/src/components/LatestBrands.css create mode 100644 apps/web/src/components/LatestBrands.tsx create mode 100644 apps/web/src/components/Layout.tsx create mode 100644 apps/web/src/components/PageHero.css create mode 100644 apps/web/src/components/PageHero.tsx create mode 100644 apps/web/src/components/Pagination.css create mode 100644 apps/web/src/components/Pagination.tsx create mode 100644 apps/web/src/components/RichHtml.tsx create mode 100644 apps/web/src/components/Slider.css create mode 100644 apps/web/src/components/Slider.tsx create mode 100644 apps/web/src/data.ts create mode 100644 apps/web/src/index.css create mode 100644 apps/web/src/lib/api.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/services.ts create mode 100644 apps/web/src/lib/types.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/AboutPage.css create mode 100644 apps/web/src/pages/AboutPage.tsx create mode 100644 apps/web/src/pages/BlogDetailPage.tsx create mode 100644 apps/web/src/pages/BlogListPage.tsx create mode 100644 apps/web/src/pages/BrandDetailPage.tsx create mode 100644 apps/web/src/pages/BrandListPage.tsx create mode 100644 apps/web/src/pages/ContactPage.css create mode 100644 apps/web/src/pages/ContactPage.tsx create mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/ReportageDetailPage.tsx create mode 100644 apps/web/src/pages/ReportageListPage.tsx create mode 100644 apps/web/src/pages/detail.css create mode 100644 apps/web/tsconfig.app.json create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/tsconfig.node.json create mode 100644 apps/web/vite.config.ts create mode 100644 docker-compose.yml create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..99d258e --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Copy to apps/api/.env for local development +DATABASE_URL=postgresql://novintrades:novintrades@localhost:5433/novintrades?schema=public +JWT_SECRET=change-me-in-production +PORT=3000 +HOST=0.0.0.0 + +# ParsPack S3-compatible storage +S3_ENDPOINT=https://c287211.parspack.net +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_BUCKET=c287211 +S3_REGION=us-east-1 +S3_PUBLIC_BASE_URL=https://c287211.parspack.net +MAX_IMAGE_BYTES=256000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1567f43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Prisma +apps/api/prisma/*.db +apps/api/prisma/*.db-journal diff --git a/README.md b/README.md new file mode 100644 index 0000000..3555e86 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# NovinTrades + +Monorepo for NovinTrades — API, admin dashboard, and public website. + +## Apps + +| App | Path | Description | +|-----|------|-------------| +| **api** | `apps/api` | Fastify REST API + PostgreSQL (Prisma) | +| **admin** | `apps/admin` | Super-admin dashboard | +| **web** | `apps/web` | Public website | + +## Quick start + +```bash +# Start Postgres (host port 5433 — 5432 may be used by other projects) +docker compose up -d + +# Install dependencies +npm install + +# Configure API env +cp .env.example apps/api/.env + +# Run migrations + seed admin user +npm run db:migrate +npm run db:seed + +# Dev servers +npm run dev:api # http://localhost:3000 +npm run dev:admin # http://localhost:5173 +npm run dev:web # http://novintrades.local:5174 +``` + +Default admin (after seed): `admin@novintrades.com` / `admin123` + +## One-VM deploy (overview) + +- Postgres via Docker +- API via PM2 (or Docker) +- Admin + web as static builds behind Nginx +- Route by subdomain or path (`api.`, `admin.`, apex) diff --git a/apps/admin/.oxlintrc.json b/apps/admin/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/apps/admin/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/apps/admin/index.html b/apps/admin/index.html new file mode 100644 index 0000000..26836f4 --- /dev/null +++ b/apps/admin/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + NovinTrades Admin + + +
+ + + diff --git a/apps/admin/package.json b/apps/admin/package.json new file mode 100644 index 0000000..531b24b --- /dev/null +++ b/apps/admin/package.json @@ -0,0 +1,33 @@ +{ + "name": "@novintrades/admin", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tiptap/extension-image": "^3.28.0", + "@tiptap/extension-link": "^3.28.0", + "@tiptap/extension-placeholder": "^3.28.0", + "@tiptap/react": "^3.28.0", + "@tiptap/starter-kit": "^3.28.0", + "lucide-react": "^1.26.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-easy-crop": "^6.2.2", + "react-router-dom": "^7.18.1" + }, + "devDependencies": { + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.3", + "oxlint": "^1.71.0", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } +} diff --git a/apps/admin/public/favicon.svg b/apps/admin/public/favicon.svg new file mode 100644 index 0000000..e7c9172 --- /dev/null +++ b/apps/admin/public/favicon.svg @@ -0,0 +1,5 @@ + + + + NT + diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx new file mode 100644 index 0000000..842b0c4 --- /dev/null +++ b/apps/admin/src/App.tsx @@ -0,0 +1,37 @@ +import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; +import { Layout } from "./components/Layout"; +import { RequireAuth } from "./components/RequireAuth"; +import { BlogNewPage } from "./pages/BlogNewPage"; +import { BlogPage } from "./pages/BlogPage"; +import { BrandNewPage } from "./pages/BrandNewPage"; +import { BrandsPage } from "./pages/BrandsPage"; +import { HomePage } from "./pages/HomePage"; +import { LoginPage } from "./pages/LoginPage"; +import { ReportageNewPage } from "./pages/ReportageNewPage"; +import { ReportagePage } from "./pages/ReportagePage"; + +export default function App() { + return ( + + + } /> + + }> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/apps/admin/src/assets/hero.png b/apps/admin/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/apps/admin/src/components/ContactFields.css b/apps/admin/src/components/ContactFields.css new file mode 100644 index 0000000..364cf24 --- /dev/null +++ b/apps/admin/src/components/ContactFields.css @@ -0,0 +1,38 @@ +.contacts__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.contacts__list { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.contacts__row { + display: grid; + grid-template-columns: 150px 1fr auto; + gap: 0.5rem; + align-items: center; +} + +.contacts__empty { + padding: 0.9rem 1rem; + border: 1px dashed var(--gray-200); + border-radius: var(--radius); + color: var(--gray-600); + font-size: 0.85rem; + background: var(--gray-50); +} + +@media (max-width: 700px) { + .contacts__row { + grid-template-columns: 1fr auto; + } + + .contacts__row .field-select { + grid-column: 1 / -1; + } +} diff --git a/apps/admin/src/components/ContactFields.tsx b/apps/admin/src/components/ContactFields.tsx new file mode 100644 index 0000000..a99d3bc --- /dev/null +++ b/apps/admin/src/components/ContactFields.tsx @@ -0,0 +1,105 @@ +import { Plus, Trash2 } from "lucide-react"; +import { IconButton } from "./IconButton"; +import "./ContactFields.css"; + +export type ContactType = + | "phone" + | "landline" + | "email" + | "instagram" + | "website" + | "whatsapp" + | "other"; + +export interface ContactItem { + id: string; + type: ContactType; + value: string; +} + +const CONTACT_TYPES: { value: ContactType; label: string }[] = [ + { value: "phone", label: "Phone" }, + { value: "landline", label: "Land line" }, + { value: "email", label: "Email" }, + { value: "instagram", label: "Instagram" }, + { value: "website", label: "Website" }, + { value: "whatsapp", label: "WhatsApp" }, + { value: "other", label: "Other" }, +]; + +interface ContactFieldsProps { + value: ContactItem[]; + onChange: (value: ContactItem[]) => void; +} + +export function createContact(type: ContactType = "phone"): ContactItem { + return { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + type, + value: "", + }; +} + +export function ContactFields({ value, onChange }: ContactFieldsProps) { + function update(id: string, patch: Partial) { + onChange(value.map((item) => (item.id === id ? { ...item, ...patch } : item))); + } + + function remove(id: string) { + onChange(value.filter((item) => item.id !== id)); + } + + return ( +
+
+ Contact info + +
+ +
+ {value.length === 0 ? ( +

No contacts yet. Add phone, email, Instagram, etc.

+ ) : ( + value.map((item) => ( +
+ + update(item.id, { value: e.target.value })} + placeholder={ + item.type === "email" + ? "name@company.com" + : item.type === "instagram" + ? "@brand" + : "Value" + } + /> + remove(item.id)}> + + +
+ )) + )} +
+
+ ); +} diff --git a/apps/admin/src/components/DataTable.css b/apps/admin/src/components/DataTable.css new file mode 100644 index 0000000..f11bcf3 --- /dev/null +++ b/apps/admin/src/components/DataTable.css @@ -0,0 +1,152 @@ +.table-wrap { + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius); + overflow: auto; +} + +.data-table { + min-width: 640px; +} + +.data-table th, +.data-table td { + text-align: left; + padding: 0.85rem 1rem; + border-bottom: 1px solid var(--gray-100); + font-size: 0.875rem; +} + +.data-table th { + background: var(--gray-50); + color: var(--gray-600); + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover td { + background: var(--blue-soft); +} + +.data-table td { + color: var(--gray-800); + vertical-align: middle; +} + +.cell-title { + font-weight: 600; + color: var(--dark-blue); +} + +.cell-muted { + color: var(--gray-600); +} + +.row-actions { + display: flex; + gap: 0.35rem; + justify-content: flex-end; +} + +.badge { + display: inline-flex; + align-items: center; + padding: 0.2rem 0.55rem; + border-radius: 4px; + font-size: 0.72rem; + font-weight: 600; + letter-spacing: 0.01em; + border: none; + font-family: inherit; +} + +.badge--clickable { + cursor: pointer; + transition: filter 0.15s ease, transform 0.15s ease; +} + +.badge--clickable:hover { + filter: brightness(0.96); +} + +.badge--clickable:focus-visible { + outline: 2px solid var(--blue); + outline-offset: 2px; +} + +.badge--approved { + background: var(--success-soft); + color: var(--success); +} + +.badge--pending { + background: var(--blue-soft); + color: var(--blue); +} + +.badge--rejected { + background: var(--danger-soft); + color: var(--danger); +} + +.toolbar { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; + flex-wrap: wrap; +} + +.search-input { + flex: 1; + min-width: 200px; + max-width: 320px; + padding: 0.55rem 0.85rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + color: var(--gray-800); + outline: none; +} + +.search-input:focus { + border-color: var(--blue); +} + +.search-input::placeholder { + color: var(--gray-400); +} + +.filter-select { + padding: 0.55rem 0.85rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + color: var(--gray-800); + outline: none; +} + +.filter-select:focus { + border-color: var(--blue); +} + +.empty-state { + padding: 3rem 1.5rem; + text-align: center; + color: var(--gray-600); + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius); +} + +.empty-state p { + margin-top: 0.35rem; + font-size: 0.9rem; +} diff --git a/apps/admin/src/components/DataTable.tsx b/apps/admin/src/components/DataTable.tsx new file mode 100644 index 0000000..eab899b --- /dev/null +++ b/apps/admin/src/components/DataTable.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import "./DataTable.css"; + +interface Column { + key: string; + header: string; + render: (row: T) => ReactNode; + width?: string; +} + +interface DataTableProps { + columns: Column[]; + rows: T[]; + rowKey: (row: T) => string; +} + +export function DataTable({ columns, rows, rowKey }: DataTableProps) { + return ( +
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
{col.render(row)}
+
+ ); +} diff --git a/apps/admin/src/components/IconButton.css b/apps/admin/src/components/IconButton.css new file mode 100644 index 0000000..5c05f80 --- /dev/null +++ b/apps/admin/src/components/IconButton.css @@ -0,0 +1,67 @@ +.icon-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: var(--radius); + color: var(--gray-600); + border: 1px solid var(--gray-200); + background: var(--white); + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.icon-btn:hover { + background: var(--gray-100); + color: var(--dark-blue); + border-color: var(--gray-200); +} + +.icon-btn--primary { + background: var(--blue); + border-color: var(--blue); + color: var(--white); +} + +.icon-btn--primary:hover { + background: var(--blue-hover); + border-color: var(--blue-hover); + color: var(--white); +} + +.icon-btn--danger { + border-color: transparent; + color: var(--danger); +} + +.icon-btn--danger:hover { + background: var(--danger-soft); + color: var(--danger); + border-color: transparent; +} + +.icon-btn::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%) translateY(2px); + padding: 0.3rem 0.5rem; + background: var(--dark-blue); + color: var(--white); + font-size: 0.7rem; + font-weight: 600; + white-space: nowrap; + border-radius: 4px; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease, transform 0.12s ease; + z-index: 20; +} + +.icon-btn:hover::after, +.icon-btn:focus-visible::after { + opacity: 1; + transform: translateX(-50%) translateY(0); +} diff --git a/apps/admin/src/components/IconButton.tsx b/apps/admin/src/components/IconButton.tsx new file mode 100644 index 0000000..a53b81e --- /dev/null +++ b/apps/admin/src/components/IconButton.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from "react"; +import "./IconButton.css"; + +interface IconButtonProps { + label: string; + onClick?: () => void; + variant?: "default" | "danger" | "primary"; + children: ReactNode; + type?: "button" | "submit"; + disabled?: boolean; +} + +export function IconButton({ + label, + onClick, + variant = "default", + children, + type = "button", + disabled = false, +}: IconButtonProps) { + return ( + + ); +} diff --git a/apps/admin/src/components/ImageCropper.css b/apps/admin/src/components/ImageCropper.css new file mode 100644 index 0000000..43e13c6 --- /dev/null +++ b/apps/admin/src/components/ImageCropper.css @@ -0,0 +1,111 @@ +.image-cropper { + width: 100%; +} + +.image-cropper__upload { + width: 100%; + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.35rem; + border: 1px dashed var(--gray-200); + border-radius: var(--radius); + background: var(--gray-50); + color: var(--gray-600); + transition: border-color 0.15s ease, background 0.15s ease; +} + +.image-cropper__upload:hover { + border-color: var(--blue); + background: var(--blue-soft); + color: var(--dark-blue); +} + +.image-cropper__upload span { + font-weight: 600; + font-size: 0.9rem; +} + +.image-cropper__upload small { + font-size: 0.75rem; + color: var(--gray-400); +} + +.image-cropper__preview { + position: relative; + width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + overflow: hidden; + background: var(--gray-100); +} + +.image-cropper__preview img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.image-cropper__preview-actions { + position: absolute; + top: 0.65rem; + right: 0.65rem; + display: flex; + gap: 0.35rem; +} + +.crop-modal { + position: fixed; + inset: 0; + z-index: 100; + background: rgba(11, 31, 58, 0.55); + display: grid; + place-items: center; + padding: 1rem; +} + +.crop-modal__panel { + width: min(720px, 100%); + background: var(--white); + border-radius: var(--radius); + overflow: hidden; +} + +.crop-modal__stage { + position: relative; + height: 420px; + background: var(--dark-blue); +} + +.crop-modal__controls { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1rem; + flex-wrap: wrap; +} + +.crop-modal__zoom { + display: flex; + align-items: center; + gap: 0.75rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--gray-600); + flex: 1; + min-width: 180px; +} + +.crop-modal__zoom input { + flex: 1; +} + +.crop-modal__actions { + display: flex; + gap: 0.5rem; +} diff --git a/apps/admin/src/components/ImageCropper.tsx b/apps/admin/src/components/ImageCropper.tsx new file mode 100644 index 0000000..5e7e7b3 --- /dev/null +++ b/apps/admin/src/components/ImageCropper.tsx @@ -0,0 +1,206 @@ +import { useCallback, useRef, useState } from "react"; +import Cropper, { type Area } from "react-easy-crop"; +import { ImagePlus, Trash2 } from "lucide-react"; +import { ApiError } from "../lib/api"; +import { + canvasToJpegBlob, + maxImageLabel, +} from "../lib/image"; +import { uploadImage } from "../lib/services"; +import { IconButton } from "./IconButton"; +import "./ImageCropper.css"; + +interface ImageCropperProps { + label: string; + value: string | null; + onChange: (value: string | null) => void; + aspect?: number; + folder?: string; +} + +function createImage(url: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.addEventListener("load", () => resolve(img)); + img.addEventListener("error", reject); + img.src = url; + }); +} + +async function getCroppedBlob(imageSrc: string, crop: Area): Promise { + const image = await createImage(imageSrc); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) throw new Error("Canvas not supported"); + + canvas.width = crop.width; + canvas.height = crop.height; + ctx.drawImage( + image, + crop.x, + crop.y, + crop.width, + crop.height, + 0, + 0, + crop.width, + crop.height, + ); + return canvasToJpegBlob(canvas, 0.85); +} + +export function ImageCropper({ + label, + value, + onChange, + aspect = 1, + folder = "uploads", +}: ImageCropperProps) { + const inputRef = useRef(null); + const [rawSrc, setRawSrc] = useState(null); + const [crop, setCrop] = useState({ x: 0, y: 0 }); + const [zoom, setZoom] = useState(1); + const [croppedArea, setCroppedArea] = useState(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + + const onCropComplete = useCallback((_: Area, croppedPixels: Area) => { + setCroppedArea(croppedPixels); + }, []); + + function onFileChange(file: File | undefined) { + if (!file) return; + setError(""); + const reader = new FileReader(); + reader.onload = () => { + setRawSrc(String(reader.result)); + setCrop({ x: 0, y: 0 }); + setZoom(1); + }; + reader.readAsDataURL(file); + } + + async function applyCrop() { + if (!rawSrc || !croppedArea) return; + setError(""); + setUploading(true); + try { + const blob = await getCroppedBlob(rawSrc, croppedArea); + const uploaded = await uploadImage(blob, folder); + onChange(uploaded.url); + setRawSrc(null); + } catch (err) { + setError( + err instanceof ApiError || err instanceof Error + ? err.message + : "Upload failed", + ); + } finally { + setUploading(false); + } + } + + return ( +
+ {label} +
+ {value ? ( +
+ Main preview +
+ inputRef.current?.click()} + disabled={uploading} + > + + + onChange(null)} + disabled={uploading} + > + + +
+
+ ) : ( + + )} + + { + onFileChange(e.target.files?.[0]); + e.target.value = ""; + }} + /> +
+ + {error ?

{error}

: null} + + {rawSrc ? ( +
+
+
+ +
+
+ +
+ + +
+
+ {error ?

{error}

: null} +
+
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/components/ImageGallery.css b/apps/admin/src/components/ImageGallery.css new file mode 100644 index 0000000..16f4121 --- /dev/null +++ b/apps/admin/src/components/ImageGallery.css @@ -0,0 +1,64 @@ +.image-gallery { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; +} + +.image-gallery__item { + position: relative; + aspect-ratio: 1 / 1; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + overflow: hidden; + background: var(--gray-100); +} + +.image-gallery__item img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.image-gallery__item-actions { + position: absolute; + top: 0.45rem; + right: 0.45rem; +} + +.image-gallery__add { + aspect-ratio: 1 / 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.3rem; + border: 1px dashed var(--gray-200); + border-radius: var(--radius); + background: var(--gray-50); + color: var(--gray-600); + padding: 0.75rem; + text-align: center; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.image-gallery__add:hover:not(:disabled) { + border-color: var(--blue); + background: var(--blue-soft); + color: var(--dark-blue); +} + +.image-gallery__add:disabled { + opacity: 0.7; + cursor: wait; +} + +.image-gallery__add span { + font-weight: 600; + font-size: 0.85rem; +} + +.image-gallery__add small { + font-size: 0.72rem; + color: var(--gray-400); +} diff --git a/apps/admin/src/components/ImageGallery.tsx b/apps/admin/src/components/ImageGallery.tsx new file mode 100644 index 0000000..32e35bf --- /dev/null +++ b/apps/admin/src/components/ImageGallery.tsx @@ -0,0 +1,99 @@ +import { useRef, useState } from "react"; +import { ImagePlus, Trash2 } from "lucide-react"; +import { ApiError } from "../lib/api"; +import { maxImageLabel } from "../lib/image"; +import { uploadImage } from "../lib/services"; +import { IconButton } from "./IconButton"; +import "./ImageGallery.css"; + +interface ImageGalleryProps { + label: string; + value: string[]; + onChange: (value: string[]) => void; + folder?: string; +} + +export function ImageGallery({ + label, + value, + onChange, + folder = "brands", +}: ImageGalleryProps) { + const inputRef = useRef(null); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(""); + + async function onFilesSelected(files: FileList | null) { + if (!files?.length) return; + setError(""); + setUploading(true); + + try { + const uploadedUrls: string[] = []; + for (const file of Array.from(files)) { + const uploaded = await uploadImage(file, folder); + uploadedUrls.push(uploaded.url); + } + onChange([...value, ...uploadedUrls]); + } catch (err) { + setError( + err instanceof ApiError || err instanceof Error + ? err.message + : "Upload failed", + ); + } finally { + setUploading(false); + } + } + + function removeAt(index: number) { + onChange(value.filter((_, i) => i !== index)); + } + + return ( +
+ {label} +
+ {value.map((url, index) => ( +
+ {`Gallery +
+ removeAt(index)} + disabled={uploading} + > + + +
+
+ ))} + + + + { + void onFilesSelected(e.target.files); + e.target.value = ""; + }} + /> +
+ {error ?

{error}

: null} +
+ ); +} diff --git a/apps/admin/src/components/Layout.css b/apps/admin/src/components/Layout.css new file mode 100644 index 0000000..d6f9eb7 --- /dev/null +++ b/apps/admin/src/components/Layout.css @@ -0,0 +1,66 @@ +.layout { + display: flex; + min-height: 100vh; + background: var(--gray-50); +} + +.layout__main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.9rem 1.75rem; + background: var(--white); + border-bottom: 1px solid var(--gray-200); +} + +.topbar__label { + font-size: 0.85rem; + font-weight: 600; + color: var(--gray-600); +} + +.topbar__meta { + display: flex; + align-items: center; + gap: 0.65rem; +} + +.topbar__chip { + display: inline-flex; + align-items: center; + padding: 0.3rem 0.65rem; + background: var(--blue-soft); + color: var(--blue); + font-size: 0.75rem; + font-weight: 700; + border-radius: 4px; +} + +.layout__content { + padding: 1.75rem; + flex: 1; +} + +@media (max-width: 800px) { + .layout { + flex-direction: column; + } + + .topbar, + .layout__content { + padding-left: 1rem; + padding-right: 1rem; + } + + .layout__content { + padding-top: 1.25rem; + padding-bottom: 1.25rem; + } +} diff --git a/apps/admin/src/components/Layout.tsx b/apps/admin/src/components/Layout.tsx new file mode 100644 index 0000000..a869723 --- /dev/null +++ b/apps/admin/src/components/Layout.tsx @@ -0,0 +1,43 @@ +import { Outlet, useNavigate } from "react-router-dom"; +import { ExternalLink, LogOut } from "lucide-react"; +import { getCurrentUser, logoutSession } from "../lib/auth"; +import { WEB_URL } from "../lib/config"; +import { IconButton } from "./IconButton"; +import { Sidebar } from "./Sidebar"; +import "./Layout.css"; + +export function Layout() { + const navigate = useNavigate(); + const user = getCurrentUser(); + + function onLogout() { + logoutSession(); + navigate("/login", { replace: true }); + } + + return ( +
+ +
+
+

Admin Dashboard

+
+ {user?.email ?? "Super Admin"} + window.open(WEB_URL, "_blank", "noopener,noreferrer")} + > + + + + + +
+
+
+ +
+
+
+ ); +} diff --git a/apps/admin/src/components/ListToolbar.css b/apps/admin/src/components/ListToolbar.css new file mode 100644 index 0000000..3019347 --- /dev/null +++ b/apps/admin/src/components/ListToolbar.css @@ -0,0 +1,7 @@ +.list-toolbar { + align-items: center; +} + +.list-toolbar .btn--primary { + gap: 0.4rem; +} diff --git a/apps/admin/src/components/ListToolbar.tsx b/apps/admin/src/components/ListToolbar.tsx new file mode 100644 index 0000000..72924c3 --- /dev/null +++ b/apps/admin/src/components/ListToolbar.tsx @@ -0,0 +1,85 @@ +import { Filter } from "lucide-react"; +import type { Category, VerificationStatus } from "../lib/types"; +import "./ListToolbar.css"; + +export interface ListFilterValues { + query: string; + status: "all" | VerificationStatus; + categoryId: string; +} + +interface ListToolbarProps { + draft: ListFilterValues; + onDraftChange: (next: ListFilterValues) => void; + onFilter: () => void; + categories: Category[]; + searchPlaceholder?: string; + filtering?: boolean; +} + +export function ListToolbar({ + draft, + onDraftChange, + onFilter, + categories, + searchPlaceholder = "Search…", + filtering = false, +}: ListToolbarProps) { + return ( +
+ onDraftChange({ ...draft, query: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onFilter(); + } + }} + /> + + + +
+ ); +} diff --git a/apps/admin/src/components/MultiSelect.css b/apps/admin/src/components/MultiSelect.css new file mode 100644 index 0000000..d1c86f4 --- /dev/null +++ b/apps/admin/src/components/MultiSelect.css @@ -0,0 +1,217 @@ +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field__label { + font-size: 0.8rem; + font-weight: 600; + color: var(--dark-blue); +} + +.field__hint { + font-size: 0.75rem; + color: var(--gray-600); +} + +.field-input, +.field-textarea, +.field-select { + width: 100%; + padding: 0.65rem 0.85rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + color: var(--gray-800); + outline: none; +} + +.field-input:focus, +.field-textarea:focus, +.field-select:focus { + border-color: var(--blue); +} + +.field-textarea { + min-height: 110px; + resize: vertical; +} + +.multiselect { + position: relative; +} + +.multiselect__control { + width: 100%; + min-height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.4rem 0.65rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + text-align: left; +} + +.multiselect--open .multiselect__control { + border-color: var(--blue); +} + +.multiselect__chips { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + flex: 1; + min-width: 0; +} + +.multiselect__placeholder { + color: var(--gray-400); + font-size: 0.875rem; + padding: 0.2rem 0.15rem; +} + +.multiselect__chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.45rem; + background: var(--blue-soft); + color: var(--blue); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.multiselect__chip-remove { + display: inline-flex; + cursor: pointer; + opacity: 0.75; +} + +.multiselect__chip-remove:hover { + opacity: 1; +} + +.multiselect__chevron { + color: var(--gray-400); + flex-shrink: 0; +} + +.multiselect__dropdown { + position: absolute; + z-index: 30; + top: calc(100% + 4px); + left: 0; + right: 0; + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius); + overflow: hidden; +} + +.multiselect__search { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + border-bottom: 1px solid var(--gray-100); + color: var(--gray-400); +} + +.multiselect__search input { + flex: 1; + border: none; + outline: none; + background: transparent; + font: inherit; + color: var(--gray-800); +} + +.multiselect__list { + list-style: none; + max-height: 320px; + overflow: auto; +} + +.multiselect__option { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.55rem 0.85rem; + font-size: 0.875rem; + color: var(--gray-800); + text-align: left; +} + +.multiselect__option-main { + display: flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + flex: 1; +} + +.multiselect__tree { + position: relative; + flex: 0 0 auto; + align-self: stretch; + display: flex; + min-height: 1.4rem; +} + +.multiselect__tree-col { + position: relative; + flex: 1 1 0; + min-width: 0; +} + +.multiselect__tree-col::before { + content: ""; + position: absolute; + left: 50%; + top: 0; + bottom: 0; + width: 1px; + background: var(--gray-200); + transform: translateX(-50%); +} + +.multiselect__tree-col.is-branch::after { + content: ""; + position: absolute; + left: 50%; + top: 50%; + width: 50%; + height: 1px; + background: var(--gray-200); +} + +.multiselect__option-label { + min-width: 0; + line-height: 1.35; +} + +.multiselect__option:hover, +.multiselect__option.is-selected { + background: var(--blue-soft); + color: var(--dark-blue); +} + +.multiselect__option:hover .multiselect__tree-col::before, +.multiselect__option:hover .multiselect__tree-col.is-branch::after, +.multiselect__option.is-selected .multiselect__tree-col::before, +.multiselect__option.is-selected .multiselect__tree-col.is-branch::after { + background: #b7c9e8; +} + +.multiselect__empty { + padding: 0.85rem; + font-size: 0.85rem; + color: var(--gray-600); +} diff --git a/apps/admin/src/components/MultiSelect.tsx b/apps/admin/src/components/MultiSelect.tsx new file mode 100644 index 0000000..90216d7 --- /dev/null +++ b/apps/admin/src/components/MultiSelect.tsx @@ -0,0 +1,193 @@ +import { useEffect, useId, useMemo, useRef, useState } from "react"; +import { Check, ChevronDown, Search, X } from "lucide-react"; +import "./MultiSelect.css"; + +export interface SelectOption { + value: string; + label: string; + depth?: number; +} + +interface MultiSelectProps { + label: string; + options: SelectOption[]; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +} + +export function MultiSelect({ + label, + options, + value, + onChange, + placeholder = "Search categories…", +}: MultiSelectProps) { + const id = useId(); + const rootRef = useRef(null); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + + const selected = useMemo( + () => options.filter((o) => value.includes(o.value)), + [options, value], + ); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return options; + // Keep matches and their ancestors so hierarchy stays readable while searching + const matched = new Set( + options.filter((o) => o.label.toLowerCase().includes(q)).map((o) => o.value), + ); + if (matched.size === 0) return []; + + const indexByValue = new Map(options.map((o, i) => [o.value, i])); + const keep = new Set(matched); + + for (const option of options) { + if (!matched.has(option.value)) continue; + const depth = option.depth ?? 0; + if (depth === 0) continue; + const idx = indexByValue.get(option.value) ?? 0; + for (let i = idx - 1; i >= 0; i -= 1) { + const ancestor = options[i]; + if ((ancestor.depth ?? 0) < depth) { + keep.add(ancestor.value); + if ((ancestor.depth ?? 0) === 0) break; + } + } + } + + return options.filter((o) => keep.has(o.value)); + }, [options, query]); + + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (!rootRef.current?.contains(e.target as Node)) { + setOpen(false); + } + } + document.addEventListener("mousedown", onDocClick); + return () => document.removeEventListener("mousedown", onDocClick); + }, []); + + function toggle(optionValue: string) { + if (value.includes(optionValue)) { + onChange(value.filter((v) => v !== optionValue)); + } else { + onChange([...value, optionValue]); + } + } + + function remove(optionValue: string) { + onChange(value.filter((v) => v !== optionValue)); + } + + return ( +
+ +
+ + + {open ? ( +
+
+ + setQuery(e.target.value)} + placeholder={placeholder} + autoFocus + /> +
+
    + {filtered.length === 0 ? ( +
  • No categories found
  • + ) : ( + filtered.map((option) => { + const active = value.includes(option.value); + const depth = option.depth ?? 0; + return ( +
  • + +
  • + ); + }) + )} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/admin/src/components/PageHeader.css b/apps/admin/src/components/PageHeader.css new file mode 100644 index 0000000..cd92a53 --- /dev/null +++ b/apps/admin/src/components/PageHeader.css @@ -0,0 +1,81 @@ +.page-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.page-header__title { + font-size: 1.5rem; + font-weight: 700; + color: var(--dark-blue); + letter-spacing: -0.02em; +} + +.page-header__desc { + margin-top: 0.25rem; + color: var(--gray-600); + font-size: 0.9rem; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.55rem 1rem; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 600; + line-height: 1; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.btn__icon { + flex-shrink: 0; + display: block; +} + +.btn--primary { + background: var(--blue); + color: var(--white); +} + +.btn--primary:hover { + background: var(--blue-hover); +} + +.btn--ghost { + background: transparent; + color: var(--gray-600); + border: 1px solid var(--gray-200); +} + +.btn--ghost:hover { + background: var(--gray-100); + color: var(--dark-blue); +} + +.btn--danger { + background: transparent; + color: var(--danger); + border: 1px solid transparent; +} + +.btn--danger:hover { + background: var(--danger-soft); +} + +.btn--sm { + padding: 0.35rem 0.65rem; + font-size: 0.8rem; +} + +@media (max-width: 600px) { + .page-header { + flex-direction: column; + align-items: stretch; + } +} diff --git a/apps/admin/src/components/PageHeader.tsx b/apps/admin/src/components/PageHeader.tsx new file mode 100644 index 0000000..141f95a --- /dev/null +++ b/apps/admin/src/components/PageHeader.tsx @@ -0,0 +1,31 @@ +import { Plus } from "lucide-react"; +import "./PageHeader.css"; + +interface PageHeaderProps { + title: string; + description: string; + actionLabel?: string; + onAction?: () => void; +} + +export function PageHeader({ + title, + description, + actionLabel, + onAction, +}: PageHeaderProps) { + return ( +
+
+

{title}

+

{description}

+
+ {actionLabel && onAction ? ( + + ) : null} +
+ ); +} diff --git a/apps/admin/src/components/Pagination.css b/apps/admin/src/components/Pagination.css new file mode 100644 index 0000000..1d3af6b --- /dev/null +++ b/apps/admin/src/components/Pagination.css @@ -0,0 +1,27 @@ +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-top: 1rem; + flex-wrap: wrap; +} + +.pagination__meta { + font-size: 0.85rem; + color: var(--gray-600); +} + +.pagination__controls { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.pagination__page { + font-size: 0.8rem; + font-weight: 600; + color: var(--dark-blue); + min-width: 5.5rem; + text-align: center; +} diff --git a/apps/admin/src/components/Pagination.tsx b/apps/admin/src/components/Pagination.tsx new file mode 100644 index 0000000..83f8223 --- /dev/null +++ b/apps/admin/src/components/Pagination.tsx @@ -0,0 +1,54 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import "./Pagination.css"; + +interface PaginationProps { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; +} + +export function Pagination({ + page, + pageSize, + total, + onPageChange, +}: PaginationProps) { + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(page, totalPages); + const from = total === 0 ? 0 : (safePage - 1) * pageSize + 1; + const to = Math.min(safePage * pageSize, total); + + return ( +
+

+ {total === 0 ? "No results" : `Showing ${from}–${to} of ${total}`} +

+
+ + + Page {safePage} / {totalPages} + + +
+
+ ); +} diff --git a/apps/admin/src/components/RequireAuth.tsx b/apps/admin/src/components/RequireAuth.tsx new file mode 100644 index 0000000..d49f39a --- /dev/null +++ b/apps/admin/src/components/RequireAuth.tsx @@ -0,0 +1,10 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { isAuthenticated } from "../lib/auth"; + +export function RequireAuth() { + if (!isAuthenticated()) { + return ; + } + + return ; +} diff --git a/apps/admin/src/components/RichTextEditor.css b/apps/admin/src/components/RichTextEditor.css new file mode 100644 index 0000000..2d6a4db --- /dev/null +++ b/apps/admin/src/components/RichTextEditor.css @@ -0,0 +1,177 @@ +.richtext { + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + overflow: hidden; +} + +.richtext__toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + padding: 0.55rem; + border-bottom: 1px solid var(--gray-100); + background: var(--gray-50); +} + +.richtext__editor .tiptap { + min-height: 220px; + padding: 0.9rem 1rem; + outline: none; +} + +.richtext__editor .tiptap p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + float: left; + color: var(--gray-400); + pointer-events: none; + height: 0; +} + +.richtext__editor .tiptap h2 { + font-size: 1.2rem; + margin: 0.85rem 0 0.4rem; + color: var(--dark-blue); +} + +.richtext__editor .tiptap p, +.richtext__editor .tiptap ul, +.richtext__editor .tiptap ol, +.richtext__editor .tiptap blockquote { + margin: 0.45rem 0; +} + +.richtext__editor .tiptap ul, +.richtext__editor .tiptap ol { + padding-left: 1.25rem; +} + +.richtext__editor .tiptap blockquote { + border-left: 3px solid var(--blue); + padding-left: 0.75rem; + color: var(--gray-600); +} + +.richtext__editor .tiptap img { + max-width: 100%; + height: auto; + display: block; + border-radius: 4px; + margin: 0.75rem 0; +} + +.richtext__editor .tiptap [data-resize-wrapper] { + position: relative; + display: inline-block; + max-width: 100%; + line-height: 0; + margin: 0.75rem 0; +} + +.richtext__editor .tiptap [data-resize-wrapper] img { + margin: 0; + max-width: none; +} + +/* Resize chrome only when the image is selected (or actively resizing) */ +.richtext__editor .tiptap [data-resize-handle] { + position: absolute; + background: var(--blue); + border: 1px solid var(--white); + border-radius: 2px; + z-index: 10; + box-shadow: 0 0 0 1px rgba(11, 31, 58, 0.15); + opacity: 0; + pointer-events: none; +} + +.richtext__editor .tiptap [data-resize-container].ProseMirror-selectednode [data-resize-handle], +.richtext__editor .tiptap [data-resize-state="true"] [data-resize-handle] { + opacity: 1; + pointer-events: auto; +} + +.richtext__editor .tiptap [data-resize-handle]:hover { + background: var(--blue-hover); +} + +.richtext__editor .tiptap [data-resize-handle="top-left"], +.richtext__editor .tiptap [data-resize-handle="top-right"], +.richtext__editor .tiptap [data-resize-handle="bottom-left"], +.richtext__editor .tiptap [data-resize-handle="bottom-right"] { + width: 8px; + height: 8px; +} + +.richtext__editor .tiptap [data-resize-handle="top-left"] { + top: -4px; + left: -4px; + cursor: nwse-resize; +} + +.richtext__editor .tiptap [data-resize-handle="top-right"] { + top: -4px; + right: -4px; + cursor: nesw-resize; +} + +.richtext__editor .tiptap [data-resize-handle="bottom-left"] { + bottom: -4px; + left: -4px; + cursor: nesw-resize; +} + +.richtext__editor .tiptap [data-resize-handle="bottom-right"] { + bottom: -4px; + right: -4px; + cursor: nwse-resize; +} + +.richtext__editor .tiptap [data-resize-handle="top"], +.richtext__editor .tiptap [data-resize-handle="bottom"] { + height: 6px; + left: 8px; + right: 8px; +} + +.richtext__editor .tiptap [data-resize-handle="top"] { + top: -3px; + cursor: ns-resize; +} + +.richtext__editor .tiptap [data-resize-handle="bottom"] { + bottom: -3px; + cursor: ns-resize; +} + +.richtext__editor .tiptap [data-resize-handle="left"], +.richtext__editor .tiptap [data-resize-handle="right"] { + width: 6px; + top: 8px; + bottom: 8px; +} + +.richtext__editor .tiptap [data-resize-handle="left"] { + left: -3px; + cursor: ew-resize; +} + +.richtext__editor .tiptap [data-resize-handle="right"] { + right: -3px; + cursor: ew-resize; +} + +.richtext__editor .tiptap [data-resize-container].ProseMirror-selectednode [data-resize-wrapper], +.richtext__editor .tiptap [data-resize-state="true"] [data-resize-wrapper] { + outline: 1px solid var(--blue); + border-radius: 4px; +} + +.richtext__editor .tiptap [data-resize-container].ProseMirror-selectednode { + outline: none; +} + +.richtext__editor .tiptap a { + color: var(--blue); + text-decoration: underline; +} diff --git a/apps/admin/src/components/RichTextEditor.tsx b/apps/admin/src/components/RichTextEditor.tsx new file mode 100644 index 0000000..4c30991 --- /dev/null +++ b/apps/admin/src/components/RichTextEditor.tsx @@ -0,0 +1,182 @@ +import { useRef } from "react"; +import { EditorContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Image from "@tiptap/extension-image"; +import Placeholder from "@tiptap/extension-placeholder"; +import Link from "@tiptap/extension-link"; +import { + Bold, + Heading2, + ImagePlus, + Italic, + Link2, + List, + ListOrdered, + Quote, +} from "lucide-react"; +import { IconButton } from "./IconButton"; +import "./RichTextEditor.css"; + +interface RichTextEditorProps { + label: string; + value: string; + onChange: (html: string) => void; + placeholder?: string; + allowImages?: boolean; +} + +export function RichTextEditor({ + label, + value, + onChange, + placeholder = "Write content…", + allowImages = true, +}: RichTextEditorProps) { + const fileRef = useRef(null); + + const editor = useEditor({ + extensions: [ + StarterKit, + ...(allowImages + ? [ + Image.configure({ + allowBase64: true, + resize: { + enabled: true, + directions: [ + "top", + "bottom", + "left", + "right", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ], + minWidth: 48, + minHeight: 48, + alwaysPreserveAspectRatio: true, + }, + }), + ] + : []), + Link.configure({ openOnClick: false }), + Placeholder.configure({ placeholder }), + ], + content: value, + immediatelyRender: false, + onUpdate: ({ editor: ed }) => onChange(ed.getHTML()), + }); + + function insertImage(file: File | undefined) { + if (!file || !editor || !allowImages) return; + const reader = new FileReader(); + reader.onload = () => { + editor + .chain() + .focus() + .setImage({ src: String(reader.result) }) + .run(); + }; + reader.readAsDataURL(file); + } + + function setLink() { + if (!editor) return; + const previous = editor.getAttributes("link").href as string | undefined; + const url = window.prompt("URL", previous ?? "https://"); + if (url === null) return; + if (url === "") { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + return; + } + editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + } + + if (!editor) return null; + + return ( +
+ {label} +
+
+ editor.chain().focus().toggleBold().run()} + > + + + editor.chain().focus().toggleItalic().run()} + > + + + editor.chain().focus().toggleHeading({ level: 2 }).run()} + > + + + editor.chain().focus().toggleBulletList().run()} + > + + + editor.chain().focus().toggleOrderedList().run()} + > + + + editor.chain().focus().toggleBlockquote().run()} + > + + + + + + {allowImages ? ( + <> + fileRef.current?.click()} + > + + + { + insertImage(e.target.files?.[0]); + e.target.value = ""; + }} + /> + + ) : null} +
+ +
+

+ {allowImages + ? "Click an image to select it, then drag the handles to resize." + : "Images are not allowed in this text. Use the gallery below."} +

+
+ ); +} diff --git a/apps/admin/src/components/RowActions.tsx b/apps/admin/src/components/RowActions.tsx new file mode 100644 index 0000000..1689f73 --- /dev/null +++ b/apps/admin/src/components/RowActions.tsx @@ -0,0 +1,20 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { IconButton } from "./IconButton"; + +interface RowActionsProps { + onEdit?: () => void; + onDelete?: () => void; +} + +export function RowActions({ onEdit, onDelete }: RowActionsProps) { + return ( +
+ + + + + + +
+ ); +} diff --git a/apps/admin/src/components/Sidebar.css b/apps/admin/src/components/Sidebar.css new file mode 100644 index 0000000..d93e39f --- /dev/null +++ b/apps/admin/src/components/Sidebar.css @@ -0,0 +1,146 @@ +.sidebar { + width: var(--sidebar-width); + min-height: 100vh; + background: var(--dark-blue); + color: var(--white); + display: flex; + flex-direction: column; + padding: 1.5rem 1rem; + position: sticky; + top: 0; + flex-shrink: 0; +} + +.sidebar__brand { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0 0.5rem 1.75rem; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + margin-bottom: 1.25rem; +} + +.sidebar__mark { + width: 36px; + height: 36px; + background: var(--blue); + color: var(--white); + display: grid; + place-items: center; + font-size: 0.8rem; + font-weight: 700; + letter-spacing: 0.02em; + border-radius: 4px; +} + +.sidebar__name { + font-size: 0.95rem; + font-weight: 700; + letter-spacing: -0.01em; +} + +.sidebar__role { + font-size: 0.72rem; + color: rgba(255, 255, 255, 0.55); + font-weight: 500; + margin-top: 0.1rem; +} + +.sidebar__nav { + display: flex; + flex-direction: column; + gap: 0.25rem; + flex: 1; +} + +.sidebar__link { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.7rem 0.75rem; + border-radius: var(--radius); + color: rgba(255, 255, 255, 0.7); + font-size: 0.9rem; + font-weight: 500; + transition: background 0.15s ease, color 0.15s ease; +} + +.sidebar__link:hover { + background: var(--dark-blue-mid); + color: var(--white); +} + +.sidebar__link--active { + background: var(--blue); + color: var(--white); +} + +.sidebar__icon { + width: 28px; + height: 28px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + display: grid; + place-items: center; + font-size: 0.68rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.sidebar__link--active .sidebar__icon { + background: rgba(255, 255, 255, 0.2); +} + +.sidebar__footer { + padding: 1rem 0.75rem 0.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.08); + margin-top: 1rem; +} + +.sidebar__footer-label { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.45); +} + +.sidebar__footer-user { + font-size: 0.8rem; + color: rgba(255, 255, 255, 0.85); + margin-top: 0.15rem; + word-break: break-all; +} + +.sidebar__footer-link { + display: inline-block; + margin-top: 0.55rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--blue); +} + +.sidebar__footer-link:hover { + color: #7eb0ff; + text-decoration: underline; +} + +@media (max-width: 800px) { + .sidebar { + width: 100%; + min-height: auto; + position: relative; + padding: 1rem; + } + + .sidebar__brand { + padding-bottom: 1rem; + margin-bottom: 0.75rem; + } + + .sidebar__nav { + flex-direction: row; + flex-wrap: wrap; + } + + .sidebar__footer { + display: none; + } +} diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx new file mode 100644 index 0000000..9e4307e --- /dev/null +++ b/apps/admin/src/components/Sidebar.tsx @@ -0,0 +1,61 @@ +import { NavLink } from "react-router-dom"; +import { Building2, FileText, Home, Newspaper } from "lucide-react"; +import { getCurrentUser } from "../lib/auth"; +import { WEB_URL } from "../lib/config"; +import "./Sidebar.css"; + +const navItems = [ + { to: "/", label: "Home", icon: Home, end: true }, + { to: "/blog", label: "Blog", icon: Newspaper }, + { to: "/reportage", label: "Reportage", icon: FileText }, + { to: "/brands", label: "Brands", icon: Building2 }, +]; + +export function Sidebar() { + const user = getCurrentUser(); + + return ( + + ); +} diff --git a/apps/admin/src/components/StatusBadge.tsx b/apps/admin/src/components/StatusBadge.tsx new file mode 100644 index 0000000..9b917c1 --- /dev/null +++ b/apps/admin/src/components/StatusBadge.tsx @@ -0,0 +1,27 @@ +import type { VerificationStatus } from "../lib/types"; +import { verificationLabel } from "../lib/format"; + +interface StatusBadgeProps { + status: VerificationStatus; + onClick?: () => void; +} + +export function StatusBadge({ status, onClick }: StatusBadgeProps) { + const tone = status.toLowerCase(); + const label = verificationLabel(status); + + if (onClick) { + return ( + + ); + } + + return {label}; +} diff --git a/apps/admin/src/components/StatusModal.css b/apps/admin/src/components/StatusModal.css new file mode 100644 index 0000000..89f79e5 --- /dev/null +++ b/apps/admin/src/components/StatusModal.css @@ -0,0 +1,141 @@ +.status-modal { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 1rem; + background: rgba(11, 31, 58, 0.45); +} + +.status-modal__panel { + width: min(440px, 100%); + background: var(--white); + border: 1px solid var(--gray-200); + border-radius: var(--radius); + padding: 1.15rem; +} + +.status-modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.status-modal__title { + font-size: 1.05rem; + font-weight: 700; + color: var(--dark-blue); +} + +.status-modal__subtitle { + margin-top: 0.2rem; + font-size: 0.85rem; + color: var(--gray-600); +} + +.status-modal__options { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.status-modal__option { + display: flex; + align-items: center; + gap: 0.75rem; + width: 100%; + padding: 0.75rem 0.85rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + text-align: left; + transition: border-color 0.15s ease, background 0.15s ease; +} + +.status-modal__option:hover:not(:disabled) { + border-color: var(--blue); + background: var(--blue-soft); +} + +.status-modal__option:disabled { + cursor: default; +} + +.status-modal__option.is-active { + border-color: var(--blue); + background: var(--blue-soft); +} + +.status-modal__option-icon { + width: 32px; + height: 32px; + display: grid; + place-items: center; + border-radius: 4px; + flex-shrink: 0; +} + +.status-modal__option--approved .status-modal__option-icon { + background: var(--success-soft); + color: var(--success); +} + +.status-modal__option--rejected .status-modal__option-icon { + background: var(--danger-soft); + color: var(--danger); +} + +.status-modal__option--pending .status-modal__option-icon { + background: var(--blue-soft); + color: var(--blue); +} + +.status-modal__option-text { + display: flex; + flex-direction: column; + gap: 0.1rem; + flex: 1; + min-width: 0; +} + +.status-modal__option-text strong { + font-size: 0.9rem; + color: var(--dark-blue); +} + +.status-modal__option-text small { + font-size: 0.75rem; + color: var(--gray-600); +} + +.status-modal__current { + font-size: 0.7rem; + font-weight: 700; + color: var(--blue); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-modal__error { + margin-top: 0.75rem; + font-size: 0.8rem; + font-weight: 600; + color: var(--danger); + background: var(--danger-soft); + padding: 0.55rem 0.7rem; + border-radius: var(--radius); +} + +.status-modal__hint, +.status-modal__footer { + margin-top: 0.75rem; + font-size: 0.8rem; + color: var(--gray-600); +} + +.status-modal__footer strong { + color: var(--dark-blue); +} diff --git a/apps/admin/src/components/StatusModal.tsx b/apps/admin/src/components/StatusModal.tsx new file mode 100644 index 0000000..c66d10a --- /dev/null +++ b/apps/admin/src/components/StatusModal.tsx @@ -0,0 +1,114 @@ +import { Check, Clock, X } from "lucide-react"; +import type { VerificationStatus } from "../lib/types"; +import { verificationLabel } from "../lib/format"; +import "./StatusModal.css"; + +const OPTIONS: { + value: VerificationStatus; + label: string; + description: string; + icon: typeof Check; +}[] = [ + { + value: "APPROVED", + label: "Approve", + description: "Mark as approved and visible when published.", + icon: Check, + }, + { + value: "REJECTED", + label: "Reject", + description: "Mark as rejected.", + icon: X, + }, + { + value: "PENDING", + label: "Pend", + description: "Keep waiting for review.", + icon: Clock, + }, +]; + +interface StatusModalProps { + title: string; + current: VerificationStatus; + saving?: boolean; + error?: string; + onSelect: (status: VerificationStatus) => void; + onClose: () => void; +} + +export function StatusModal({ + title, + current, + saving = false, + error = "", + onSelect, + onClose, +}: StatusModalProps) { + return ( +
+
e.stopPropagation()} + > +
+
+

+ Change status +

+

{title}

+
+ +
+ +
+ {OPTIONS.map((option) => { + const Icon = option.icon; + const active = current === option.value; + return ( + + ); + })} +
+ + {error ?

{error}

: null} + {saving ?

Saving…

: null} + +

+ Current: {verificationLabel(current)} +

+
+
+ ); +} diff --git a/apps/admin/src/components/TagInput.css b/apps/admin/src/components/TagInput.css new file mode 100644 index 0000000..7ae6912 --- /dev/null +++ b/apps/admin/src/components/TagInput.css @@ -0,0 +1,52 @@ +.tag-input { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + min-height: 42px; + padding: 0.4rem 0.55rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); +} + +.tag-input:focus-within { + border-color: var(--blue); +} + +.tag-input__chip { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.2rem 0.45rem; + background: var(--blue-soft); + color: var(--blue); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.tag-input__remove { + display: inline-flex; + color: inherit; + opacity: 0.75; +} + +.tag-input__remove:hover { + opacity: 1; +} + +.tag-input__field { + flex: 1; + min-width: 140px; + border: none; + outline: none; + background: transparent; + font: inherit; + color: var(--gray-800); + padding: 0.2rem 0.15rem; +} + +.tag-input__field::placeholder { + color: var(--gray-400); +} diff --git a/apps/admin/src/components/TagInput.tsx b/apps/admin/src/components/TagInput.tsx new file mode 100644 index 0000000..bbf1d82 --- /dev/null +++ b/apps/admin/src/components/TagInput.tsx @@ -0,0 +1,69 @@ +import { useState, type KeyboardEvent } from "react"; +import { X } from "lucide-react"; +import "./TagInput.css"; + +interface TagInputProps { + label: string; + value: string[]; + onChange: (value: string[]) => void; + placeholder?: string; +} + +export function TagInput({ + label, + value, + onChange, + placeholder = "Type a tag and press Enter", +}: TagInputProps) { + const [draft, setDraft] = useState(""); + + function addTag(raw: string) { + const tag = raw.trim().replace(/^#/, ""); + if (!tag) return; + if (value.some((item) => item.toLowerCase() === tag.toLowerCase())) { + setDraft(""); + return; + } + onChange([...value, tag]); + setDraft(""); + } + + function onKeyDown(e: KeyboardEvent) { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault(); + addTag(draft); + } else if (e.key === "Backspace" && !draft && value.length > 0) { + onChange(value.slice(0, -1)); + } + } + + return ( +
+ +
+ {value.map((tag) => ( + + {tag} + + + ))} + setDraft(e.target.value)} + onKeyDown={onKeyDown} + onBlur={() => addTag(draft)} + placeholder={value.length === 0 ? placeholder : "Add another…"} + /> +
+

Press Enter to add a tag.

+
+ ); +} diff --git a/apps/admin/src/components/TitlePreviewCell.css b/apps/admin/src/components/TitlePreviewCell.css new file mode 100644 index 0000000..e8a05d3 --- /dev/null +++ b/apps/admin/src/components/TitlePreviewCell.css @@ -0,0 +1,65 @@ +.title-preview { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + min-width: 220px; + max-width: 440px; +} + +.title-preview__media { + flex: 0 0 60px; + width: 60px; + height: 60px; + max-height: 60px; + border-radius: 4px; + overflow: hidden; + background: var(--gray-100); + border: 1px solid var(--gray-200); +} + +.title-preview__media img { + display: block; + width: 100%; + height: 100%; + max-height: 60px; + object-fit: cover; +} + +.title-preview__placeholder { + display: block; + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--gray-100), var(--gray-200)); +} + +.title-preview__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 0.15rem; +} + +.title-preview__title { + margin: 0; + font-weight: 600; + color: var(--dark-blue); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.title-preview__abstract { + margin: 0; + font-size: 0.8rem; + line-height: 1.35; + color: var(--gray-600); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/apps/admin/src/components/TitlePreviewCell.tsx b/apps/admin/src/components/TitlePreviewCell.tsx new file mode 100644 index 0000000..bc7be13 --- /dev/null +++ b/apps/admin/src/components/TitlePreviewCell.tsx @@ -0,0 +1,31 @@ +import "./TitlePreviewCell.css"; + +interface TitlePreviewCellProps { + title: string; + abstract?: string | null; + imageUrl?: string | null; +} + +export function TitlePreviewCell({ + title, + abstract, + imageUrl, +}: TitlePreviewCellProps) { + return ( +
+
+ {imageUrl ? ( + + ) : ( + + )} +
+
+

{title}

+

+ {abstract?.trim() || "No abstract"} +

+
+
+ ); +} diff --git a/apps/admin/src/data/options.ts b/apps/admin/src/data/options.ts new file mode 100644 index 0000000..f37be16 --- /dev/null +++ b/apps/admin/src/data/options.ts @@ -0,0 +1,12 @@ +export const countries = [ + "Iran", + "UAE", + "Turkey", + "Saudi Arabia", + "Qatar", + "Oman", + "Germany", + "China", + "India", + "Other", +]; diff --git a/apps/admin/src/index.css b/apps/admin/src/index.css new file mode 100644 index 0000000..1593328 --- /dev/null +++ b/apps/admin/src/index.css @@ -0,0 +1,160 @@ +:root { + --blue: #1a6cf0; + --blue-hover: #1558c9; + --blue-soft: #e8f0fe; + --dark-blue: #0b1f3a; + --dark-blue-mid: #132a4a; + --white: #ffffff; + --gray-50: #f5f7fa; + --gray-100: #eef1f6; + --gray-200: #dde3ec; + --gray-400: #8b97a8; + --gray-600: #5a6577; + --gray-800: #1f2937; + --danger: #d64545; + --danger-soft: #fdecec; + --success: #1f9d6c; + --success-soft: #e6f7f0; + --sidebar-width: 240px; + --radius: 6px; + --font: "Plus Jakarta Sans", sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body, +#root { + min-height: 100%; +} + +body { + font-family: var(--font); + background: var(--gray-50); + color: var(--gray-800); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + cursor: pointer; + border: none; + background: none; +} + +img { + display: block; + max-width: 100%; +} + +table { + border-collapse: collapse; + width: 100%; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field__label { + font-size: 0.8rem; + font-weight: 600; + color: var(--dark-blue); +} + +.field__hint { + font-size: 0.75rem; + color: var(--gray-600); +} + +.field-input, +.field-textarea, +.field-select { + width: 100%; + padding: 0.65rem 0.85rem; + border: 1px solid var(--gray-200); + border-radius: var(--radius); + background: var(--white); + color: var(--gray-800); + outline: none; +} + +.field-input:focus, +.field-textarea:focus, +.field-select:focus { + border-color: var(--blue); +} + +.field-textarea { + min-height: 110px; + resize: vertical; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.55rem 1rem; + border-radius: var(--radius); + font-size: 0.875rem; + font-weight: 600; + transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; + white-space: nowrap; +} + +.btn--primary { + background: var(--blue); + color: var(--white); +} + +.btn--primary:hover { + background: var(--blue-hover); +} + +.btn--ghost { + background: transparent; + color: var(--gray-600); + border: 1px solid var(--gray-200); +} + +.btn--ghost:hover { + background: var(--gray-100); + color: var(--dark-blue); +} + +.btn--danger { + background: transparent; + color: var(--danger); + border: 1px solid transparent; +} + +.btn--danger:hover { + background: var(--danger-soft); +} + +.btn--sm { + padding: 0.35rem 0.65rem; + font-size: 0.8rem; +} + diff --git a/apps/admin/src/lib/api.ts b/apps/admin/src/lib/api.ts new file mode 100644 index 0000000..e7d9699 --- /dev/null +++ b/apps/admin/src/lib/api.ts @@ -0,0 +1,42 @@ +export class ApiError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function parseError(response: Response): Promise { + try { + const data = (await response.json()) as { error?: unknown }; + if (typeof data.error === "string") return data.error; + return response.statusText || "Request failed"; + } catch { + return response.statusText || "Request failed"; + } +} + +export async function api( + path: string, + init?: RequestInit, +): Promise { + const response = await fetch(path, { + ...init, + headers: { + "Content-Type": "application/json", + ...(init?.headers ?? {}), + }, + }); + + if (!response.ok) { + throw new ApiError(response.status, await parseError(response)); + } + + if (response.status === 204) { + return undefined as T; + } + + return (await response.json()) as T; +} diff --git a/apps/admin/src/lib/auth.ts b/apps/admin/src/lib/auth.ts new file mode 100644 index 0000000..9aa6bba --- /dev/null +++ b/apps/admin/src/lib/auth.ts @@ -0,0 +1,35 @@ +import type { AuthUser } from "./types"; + +const AUTH_KEY = "novintrades_admin_session"; + +interface Session { + user: AuthUser; +} + +export function getSession(): Session | null { + const raw = localStorage.getItem(AUTH_KEY); + if (!raw) return null; + + try { + return JSON.parse(raw) as Session; + } catch { + localStorage.removeItem(AUTH_KEY); + return null; + } +} + +export function isAuthenticated(): boolean { + return getSession() !== null; +} + +export function getCurrentUser(): AuthUser | null { + return getSession()?.user ?? null; +} + +export function loginSession(user: AuthUser): void { + localStorage.setItem(AUTH_KEY, JSON.stringify({ user })); +} + +export function logoutSession(): void { + localStorage.removeItem(AUTH_KEY); +} diff --git a/apps/admin/src/lib/categories.ts b/apps/admin/src/lib/categories.ts new file mode 100644 index 0000000..95d792f --- /dev/null +++ b/apps/admin/src/lib/categories.ts @@ -0,0 +1,36 @@ +import type { SelectOption } from "../components/MultiSelect"; +import type { Category } from "./types"; + +/** Flatten categories into tree order with depth for indented multi-select. */ +export function flattenCategoryOptions( + categories: Category[], +): SelectOption[] { + const byParent = new Map(); + + for (const category of categories) { + const key = category.parentId; + const list = byParent.get(key) ?? []; + list.push(category); + byParent.set(key, list); + } + + for (const list of byParent.values()) { + list.sort((a, b) => a.name.localeCompare(b.name)); + } + + const options: SelectOption[] = []; + + function walk(parentId: string | null, depth: number) { + for (const category of byParent.get(parentId) ?? []) { + options.push({ + value: category.id, + label: category.name, + depth, + }); + walk(category.id, depth + 1); + } + } + + walk(null, 0); + return options; +} diff --git a/apps/admin/src/lib/config.ts b/apps/admin/src/lib/config.ts new file mode 100644 index 0000000..8586c1d --- /dev/null +++ b/apps/admin/src/lib/config.ts @@ -0,0 +1,3 @@ +/** Public website URL (override with VITE_WEB_URL). */ +export const WEB_URL = + import.meta.env.VITE_WEB_URL ?? "http://novintrades.local:5174"; diff --git a/apps/admin/src/lib/format.ts b/apps/admin/src/lib/format.ts new file mode 100644 index 0000000..ba08ac8 --- /dev/null +++ b/apps/admin/src/lib/format.ts @@ -0,0 +1,31 @@ +import type { Category, VerificationStatus } from "./types"; + +export function formatDate(value: string | null | undefined): string { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + const day = date.toLocaleDateString("en-CA"); + const time = date.toLocaleTimeString("en-GB", { + hour: "2-digit", + minute: "2-digit", + }); + return `${day} ${time}`; +} + +export function categoryNames( + links: { category: Pick }[], +): string { + if (!links.length) return "—"; + return links.map((link) => link.category.name).join(", "); +} + +export function verificationLabel(status: VerificationStatus): string { + switch (status) { + case "APPROVED": + return "Approved"; + case "REJECTED": + return "Rejected"; + default: + return "Pending"; + } +} diff --git a/apps/admin/src/lib/image.ts b/apps/admin/src/lib/image.ts new file mode 100644 index 0000000..f99cae4 --- /dev/null +++ b/apps/admin/src/lib/image.ts @@ -0,0 +1,27 @@ +export const MAX_IMAGE_BYTES = 250 * 1024; + +export function maxImageLabel(): string { + return `${Math.round(MAX_IMAGE_BYTES / 1024)}KB`; +} + +export function assertClientImageSize(bytes: number): void { + if (bytes > MAX_IMAGE_BYTES) { + throw new Error(`Image must be ${maxImageLabel()} or smaller`); + } +} + +export async function dataUrlToBlob(dataUrl: string): Promise { + const response = await fetch(dataUrl); + return response.blob(); +} + +export async function canvasToJpegBlob( + canvas: HTMLCanvasElement, + quality = 0.85, +): Promise { + const blob = await new Promise((resolve) => { + canvas.toBlob((result) => resolve(result), "image/jpeg", quality); + }); + if (!blob) throw new Error("Could not encode image"); + return blob; +} diff --git a/apps/admin/src/lib/list.ts b/apps/admin/src/lib/list.ts new file mode 100644 index 0000000..3fe8278 --- /dev/null +++ b/apps/admin/src/lib/list.ts @@ -0,0 +1,44 @@ +import type { ListFilterValues } from "../components/ListToolbar"; +import type { VerificationStatus } from "./types"; + +export const PAGE_SIZE = 10; + +export const emptyFilters: ListFilterValues = { + query: "", + status: "all", + categoryId: "", +}; + +export function matchesListFilters< + T extends { + title: string; + verificationStatus: VerificationStatus; + categories: { categoryId: string }[]; + }, +>( + row: T, + filters: ListFilterValues, + extraSearch?: string, +): boolean { + const q = filters.query.trim().toLowerCase(); + const haystack = `${row.title} ${extraSearch ?? ""}`.toLowerCase(); + const matchesQuery = !q || haystack.includes(q); + const matchesStatus = + filters.status === "all" || row.verificationStatus === filters.status; + const matchesCategory = + !filters.categoryId || + row.categories.some((link) => link.categoryId === filters.categoryId); + return matchesQuery && matchesStatus && matchesCategory; +} + +export function paginateRows(rows: T[], page: number, pageSize = PAGE_SIZE) { + const total = rows.length; + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const safePage = Math.min(Math.max(1, page), totalPages); + const start = (safePage - 1) * pageSize; + return { + page: safePage, + total, + items: rows.slice(start, start + pageSize), + }; +} diff --git a/apps/admin/src/lib/services.ts b/apps/admin/src/lib/services.ts new file mode 100644 index 0000000..5fa30fd --- /dev/null +++ b/apps/admin/src/lib/services.ts @@ -0,0 +1,202 @@ +import { api, ApiError } from "./api"; +import { assertClientImageSize } from "./image"; +import type { + AuthUser, + Blog, + Brand, + BrandContact, + Category, + Reportage, + VerificationStatus, +} from "./types"; + +export function login(email: string, password: string) { + return api<{ user: AuthUser }>("/api/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); +} + +export function listCategories() { + return api("/api/categories"); +} + +export function listBlogs() { + return api("/api/blogs"); +} + +export function getBlog(id: string) { + return api(`/api/blogs/${id}`); +} + +export function createBlog(input: { + title: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + tags?: string[]; + categoryIds?: string[]; + authorId: string; + verificationStatus?: VerificationStatus; +}) { + return api("/api/blogs", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateBlog( + id: string, + input: { + title?: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + tags?: string[]; + categoryIds?: string[]; + verificationStatus?: VerificationStatus; + publishedAt?: string | null; + }, +) { + return api(`/api/blogs/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function listReportages() { + return api("/api/reportages"); +} + +export function getReportage(id: string) { + return api(`/api/reportages/${id}`); +} + +export function createReportage(input: { + title: string; + businessOwner: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + tags?: string[]; + categoryIds?: string[]; + authorId: string; + verificationStatus?: VerificationStatus; +}) { + return api("/api/reportages", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateReportage( + id: string, + input: { + title?: string; + businessOwner?: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + tags?: string[]; + categoryIds?: string[]; + verificationStatus?: VerificationStatus; + publishedAt?: string | null; + }, +) { + return api(`/api/reportages/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export function listBrands() { + return api("/api/brands"); +} + +export function getBrand(id: string) { + return api(`/api/brands/${id}`); +} + +export function createBrand(input: { + title: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + galleryUrls?: string[]; + tags?: string[]; + country: string; + city?: string; + address?: string; + contacts?: BrandContact[]; + categoryIds?: string[]; + authorId: string; + verificationStatus?: VerificationStatus; +}) { + return api("/api/brands", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export function updateBrand( + id: string, + input: { + title?: string; + abstract?: string; + content?: string; + imageUrl?: string | null; + galleryUrls?: string[]; + tags?: string[]; + country?: string; + city?: string; + address?: string; + contacts?: BrandContact[]; + categoryIds?: string[]; + verificationStatus?: VerificationStatus; + publishedAt?: string | null; + }, +) { + return api(`/api/brands/${id}`, { + method: "PATCH", + body: JSON.stringify(input), + }); +} + +export async function uploadImage( + file: Blob, + folder: string, +): Promise<{ url: string; key: string; size: number }> { + assertClientImageSize(file.size); + + const form = new FormData(); + const filename = + file instanceof File && file.name + ? file.name + : `image.${file.type.includes("png") ? "png" : "jpg"}`; + form.append("file", file, filename); + + const response = await fetch( + `/api/uploads?folder=${encodeURIComponent(folder)}`, + { + method: "POST", + body: form, + }, + ); + + if (!response.ok) { + let message = "Upload failed"; + try { + const data = (await response.json()) as { error?: string }; + if (data.error) message = data.error; + } catch { + // ignore + } + throw new ApiError(response.status, message); + } + + return (await response.json()) as { + url: string; + key: string; + size: number; + }; +} diff --git a/apps/admin/src/lib/types.ts b/apps/admin/src/lib/types.ts new file mode 100644 index 0000000..5680510 --- /dev/null +++ b/apps/admin/src/lib/types.ts @@ -0,0 +1,89 @@ +export type VerificationStatus = "PENDING" | "APPROVED" | "REJECTED"; + +export interface AuthUser { + id: string; + email: string; + name: string | null; +} + +export interface Category { + id: string; + name: string; + slug: string; + parentId: string | null; + children?: Category[]; +} + +export interface CategoryLink { + categoryId: string; + category: Category; +} + +export interface Blog { + id: string; + title: string; + slug: string; + abstract: string | null; + content: string; + imageUrl: string | null; + tags: string[]; + verificationStatus: VerificationStatus; + authorId: string; + author: AuthUser; + categories: CategoryLink[]; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface Reportage { + id: string; + title: string; + slug: string; + businessOwner: string; + abstract: string | null; + content: string; + imageUrl: string | null; + tags: string[]; + verificationStatus: VerificationStatus; + authorId: string; + author: AuthUser; + categories: CategoryLink[]; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface BrandContact { + type: + | "phone" + | "landline" + | "email" + | "instagram" + | "website" + | "whatsapp" + | "other"; + value: string; +} + +export interface Brand { + id: string; + title: string; + slug: string; + abstract: string | null; + content: string; + imageUrl: string | null; + galleryUrls: string[]; + tags: string[]; + country: string; + city: string | null; + address: string | null; + contacts: BrandContact[]; + verificationStatus: VerificationStatus; + authorId: string; + author: AuthUser; + categories: CategoryLink[]; + publishedAt: string | null; + createdAt: string; + updatedAt: string; +} diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx new file mode 100644 index 0000000..c2a145c --- /dev/null +++ b/apps/admin/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/apps/admin/src/pages/BlogNewPage.tsx b/apps/admin/src/pages/BlogNewPage.tsx new file mode 100644 index 0000000..3063152 --- /dev/null +++ b/apps/admin/src/pages/BlogNewPage.tsx @@ -0,0 +1,219 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { ImageCropper } from "../components/ImageCropper"; +import { MultiSelect } from "../components/MultiSelect"; +import { PageHeader } from "../components/PageHeader"; +import { RichTextEditor } from "../components/RichTextEditor"; +import { TagInput } from "../components/TagInput"; +import { ApiError } from "../lib/api"; +import { getCurrentUser } from "../lib/auth"; +import { flattenCategoryOptions } from "../lib/categories"; +import { + createBlog, + getBlog, + listCategories, + updateBlog, +} from "../lib/services"; +import type { SelectOption } from "../components/MultiSelect"; +import "../styles/forms.css"; + +export function BlogNewPage() { + const navigate = useNavigate(); + const { id } = useParams(); + const isEdit = Boolean(id); + const user = getCurrentUser(); + + const [categoryOptions, setCategoryOptions] = useState([]); + const [categories, setCategories] = useState([]); + const [title, setTitle] = useState(""); + const [image, setImage] = useState(null); + const [abstract, setAbstract] = useState(""); + const [content, setContent] = useState(""); + const [tags, setTags] = useState([]); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [loading, setLoading] = useState(isEdit); + const [ready, setReady] = useState(!isEdit); + + useEffect(() => { + let cancelled = false; + + async function load() { + try { + const data = await listCategories(); + if (cancelled) return; + setCategoryOptions(flattenCategoryOptions(data)); + + if (id) { + const blog = await getBlog(id); + if (cancelled) return; + setTitle(blog.title); + setAbstract(blog.abstract ?? ""); + setContent(blog.content); + setImage(blog.imageUrl); + setTags(blog.tags); + setCategories(blog.categories.map((c) => c.categoryId)); + setReady(true); + } + } catch (err) { + if (!cancelled) { + setError( + err instanceof ApiError ? err.message : "Could not load post.", + ); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + void load(); + return () => { + cancelled = true; + }; + }, [id]); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setError(""); + + if (!user) { + setError("You must be signed in."); + return; + } + + setSubmitting(true); + try { + const payload = { + title: title.trim(), + abstract: abstract.trim() || undefined, + content, + imageUrl: image, + tags, + categoryIds: categories, + }; + + if (isEdit && id) { + await updateBlog(id, payload); + } else { + await createBlog({ ...payload, authorId: user.id }); + } + navigate("/blog"); + } catch (err) { + setError( + err instanceof ApiError ? err.message : "Could not save post.", + ); + } finally { + setSubmitting(false); + } + } + + if (loading) { + return ( +
+ +
+ Loading post… +
+
+ ); + } + + return ( +
+ + +
+
+ +
+ +
+
+ + +
+ + setTitle(e.target.value)} + placeholder="Post title" + required + /> +
+ +
+ +