commit 722a520e68a02dfdef23dcae0a598c5ed2182667 Author: Alireza Hassani Date: Fri Jul 24 17:16:53 2026 +0330 Initial commit: NovinTrades website monorepo. Co-authored-by: Cursor 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 0000000..02251f4 Binary files /dev/null and b/apps/admin/src/assets/hero.png differ 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 + /> +
+ +
+ +