mirror of
https://git.meshkee.com/Meshkee/dashboards.git
synced 2026-08-11 22:30:58 +04:30
Improve portfolio title images, branding, and super-admin domains UI.
Add aspect-ratio crop presets for portfolio title images, fixed card image placeholders with contained centering, Meshkee favicon/logo fallbacks, login branding updates, category FAB alignment, and super-admin website/domain SSL tooling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
f566387c61
commit
db52a83f98
@@ -28,6 +28,19 @@ import { flattenCategories } from '../utils/categories'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import styles from './AddNewProductPage.module.css'
|
||||
|
||||
type TitleImageAspectId = 'square' | '3:2' | '16:9' | '9:16'
|
||||
|
||||
const TITLE_IMAGE_ASPECTS: {
|
||||
id: TitleImageAspectId
|
||||
label: string
|
||||
aspect: number
|
||||
}[] = [
|
||||
{ id: 'square', label: 'Square', aspect: 1 },
|
||||
{ id: '3:2', label: '3:2', aspect: 3 / 2 },
|
||||
{ id: '16:9', label: '16:9', aspect: 16 / 9 },
|
||||
{ id: '9:16', label: '9:16 (Reel)', aspect: 9 / 16 },
|
||||
]
|
||||
|
||||
export function AddNewPortfolioPage() {
|
||||
const { id } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -40,6 +53,7 @@ export function AddNewPortfolioPage() {
|
||||
const [abstract, setAbstract] = useState('')
|
||||
const [mainTextHtml, setMainTextHtml] = useState('')
|
||||
const [titleImage, setTitleImage] = useState<string | null>(null)
|
||||
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
|
||||
const [images, setImages] = useState<string[]>([])
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
||||
@@ -49,6 +63,8 @@ export function AddNewPortfolioPage() {
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const categoryOptions = flattenCategories(categories)
|
||||
const selectedAspect =
|
||||
TITLE_IMAGE_ASPECTS.find((option) => option.id === titleImageAspect)?.aspect ?? 3 / 2
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -199,65 +215,96 @@ export function AddNewPortfolioPage() {
|
||||
|
||||
<form className={styles.form} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGrid}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan3}`}>
|
||||
<div className={`${styles.field} ${styles.col3} ${styles.thumbnailField}`}>
|
||||
<label>Title Image</label>
|
||||
<div
|
||||
className={styles.aspectOptions}
|
||||
role="radiogroup"
|
||||
aria-label="Title image aspect ratio"
|
||||
>
|
||||
{TITLE_IMAGE_ASPECTS.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={titleImageAspect === option.id}
|
||||
className={`${styles.aspectChip} ${
|
||||
titleImageAspect === option.id ? styles.aspectChipSelected : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (option.id === titleImageAspect) return
|
||||
setTitleImageAspect(option.id)
|
||||
// Aspect only applies during crop — clear so the user re-crops
|
||||
if (titleImage) {
|
||||
setTitleImage(null)
|
||||
setFeaturedMediaId(null)
|
||||
}
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ImageCropper
|
||||
value={titleImage}
|
||||
onChange={setTitleImage}
|
||||
aspect={3 / 2}
|
||||
aspect={selectedAspect}
|
||||
uploadLabel="Upload title image"
|
||||
hint="Click to select, then crop"
|
||||
changeLabel="Change title image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col6Span}`}>
|
||||
<label>Category</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formColumn}>
|
||||
<div className={styles.field}>
|
||||
<label>Category</label>
|
||||
<SearchableSelect
|
||||
options={categoryOptions}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder="Search and select category..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col9}`}>
|
||||
<label htmlFor="portfolio-title">Title</label>
|
||||
<input
|
||||
id="portfolio-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Portfolio title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-title">Title</label>
|
||||
<input
|
||||
id="portfolio-title"
|
||||
name="title"
|
||||
type="text"
|
||||
placeholder="Portfolio title"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col9}`}>
|
||||
<label htmlFor="portfolio-abstract">Abstract</label>
|
||||
<textarea
|
||||
id="portfolio-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in portfolio listings"
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<label htmlFor="portfolio-abstract">Abstract</label>
|
||||
<textarea
|
||||
id="portfolio-abstract"
|
||||
name="abstract"
|
||||
rows={4}
|
||||
placeholder="Short summary shown in portfolio listings"
|
||||
value={abstract}
|
||||
onChange={(e) => setAbstract(e.target.value)}
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
<label>Main Text</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder="Full portfolio content with formatting and images..."
|
||||
allowImages
|
||||
editorMinHeight={320}
|
||||
/>
|
||||
<div className={styles.field}>
|
||||
<label>Main Text</label>
|
||||
<RichTextEditor
|
||||
value={mainTextHtml}
|
||||
onChange={setMainTextHtml}
|
||||
placeholder="Full portfolio content with formatting and images..."
|
||||
allowImages
|
||||
editorMinHeight={280}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.field} ${styles.col12}`}>
|
||||
|
||||
@@ -26,6 +26,14 @@
|
||||
.col9 { grid-column: span 9; }
|
||||
.col12 { grid-column: span 12; }
|
||||
|
||||
.formColumn {
|
||||
grid-column: span 9;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.thumbnailField {
|
||||
align-self: start;
|
||||
}
|
||||
@@ -176,6 +184,41 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.aspectOptions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.aspectChip {
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 50px;
|
||||
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.aspectChip:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.aspectChip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.aspectChipSelected {
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.form {
|
||||
padding: 20px 16px;
|
||||
@@ -192,7 +235,8 @@
|
||||
.col10,
|
||||
.col4Start,
|
||||
.col9,
|
||||
.col12 {
|
||||
.col12,
|
||||
.formColumn {
|
||||
grid-column: span 12;
|
||||
}
|
||||
|
||||
|
||||
@@ -155,14 +155,6 @@ export function BlogCategoriesPage() {
|
||||
Organize your blog posts into categories and subcategories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.addBtn}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
>
|
||||
<Plus size={22} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -217,6 +209,17 @@ export function BlogCategoriesPage() {
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,3 +24,45 @@
|
||||
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.fabDock {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.addFab {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
box-shadow: 0 8px 24px rgba(var(--primary-rgb) / 0.4);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.addFab:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px rgba(var(--primary-rgb) / 0.45);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.fabDock {
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.addFab {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
updateProductCategory,
|
||||
} from '../services/productCategoryService'
|
||||
import pageStyles from '../components/PageContent.module.css'
|
||||
import fabStyles from './MyProductsPage.module.css'
|
||||
import aiStyles from '../styles/ai.module.css'
|
||||
import styles from './CategoriesPage.module.css'
|
||||
|
||||
@@ -534,7 +533,7 @@ export function CategoriesPage() {
|
||||
isRunning={aiGenerating}
|
||||
/>
|
||||
|
||||
<div className={fabStyles.fabDock}>
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={aiStyles.aiFabStrip}
|
||||
@@ -545,7 +544,7 @@ export function CategoriesPage() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={fabStyles.addFab}
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
>
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.domain {
|
||||
.businessName {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
@@ -275,13 +275,6 @@
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.domainHint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: -12px 0 20px;
|
||||
}
|
||||
|
||||
.fieldRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Eye, EyeOff, Smartphone, Lock, KeyRound, ArrowLeft, User } from 'lucide-react'
|
||||
import { useAuth, BUSINESS_ACCESS_MESSAGE } from '../context/AuthContext'
|
||||
import { useTenantBranding } from '../context/TenantBrandingContext'
|
||||
import { ApiError } from '../lib/api'
|
||||
import { toE164CellNumber } from '../lib/cellNumber'
|
||||
import { getBusinessDomain } from '../lib/config'
|
||||
@@ -21,6 +22,7 @@ type SmsStep = 'phone' | 'code'
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
const { login } = useAuth()
|
||||
const { businessName, logoUrl } = useTenantBranding()
|
||||
const businessDomain = getBusinessDomain()
|
||||
|
||||
const [view, setView] = useState<AuthView>('login')
|
||||
@@ -217,15 +219,13 @@ export function LoginPage() {
|
||||
<div className={styles.page}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.brand}>
|
||||
<img src={meshkeeLogo} alt="Meshkee" className={styles.logo} />
|
||||
<img src={logoUrl || meshkeeLogo} alt="" className={styles.logo} />
|
||||
<div className={styles.brandText}>
|
||||
<span className={styles.domain}>Sanihome.ir</span>
|
||||
<span className={styles.appName}>Meshkee.app</span>
|
||||
<span className={styles.businessName}>{businessName || businessDomain}</span>
|
||||
<span className={styles.appName}>powered by Meshkee.app</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className={styles.domainHint}>Business domain: {businessDomain}</p>
|
||||
|
||||
{view === 'login' && (
|
||||
<>
|
||||
<h1 className={styles.title}>Welcome back</h1>
|
||||
|
||||
@@ -155,14 +155,6 @@ export function PortfolioCategoriesPage() {
|
||||
Organize your portfolio items into categories and subcategories.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={styles.addBtn}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
>
|
||||
<Plus size={22} strokeWidth={2.5} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
@@ -217,6 +209,17 @@ export function PortfolioCategoriesPage() {
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => !isSubmitting && setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<div className={styles.fabDock}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addFab}
|
||||
onClick={() => openCreateModal()}
|
||||
aria-label="Add category"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,23 +7,26 @@
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0 auto 20px;
|
||||
aspect-ratio: 3 / 2;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
border: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.heroImageWrapEmpty {
|
||||
aspect-ratio: 3 / 2;
|
||||
}
|
||||
|
||||
.heroImage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.heroPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 180px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(148, 163, 184, 0.12) 0%,
|
||||
|
||||
@@ -100,7 +100,11 @@ export function PortfolioDetailsPage() {
|
||||
</div>
|
||||
|
||||
<article className={styles.portfolioDetail}>
|
||||
<div className={styles.heroImageWrap}>
|
||||
<div
|
||||
className={`${styles.heroImageWrap} ${
|
||||
portfolio.titleImageUrl ? '' : styles.heroImageWrapEmpty
|
||||
}`}
|
||||
>
|
||||
{portfolio.titleImageUrl ? (
|
||||
<img
|
||||
src={portfolio.titleImageUrl}
|
||||
|
||||
Reference in New Issue
Block a user