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
@@ -1,5 +1,16 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { AlertTriangle, Lock, Pencil, RotateCcw, Search, Trash2, Unlock } from 'lucide-react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import {
|
||||
AlertTriangle,
|
||||
Loader2,
|
||||
Lock,
|
||||
Pencil,
|
||||
Rocket,
|
||||
RotateCcw,
|
||||
Search,
|
||||
Trash2,
|
||||
Unlock,
|
||||
} from 'lucide-react'
|
||||
import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
||||
import { Modal } from '../components/Modal'
|
||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||
@@ -7,6 +18,7 @@ import type { DomainListItem, DomainsListResponse } from '../types/domain'
|
||||
import type { ListDomainsParams } from '../services/domainService'
|
||||
import { ApiError, isAbortError } from '../lib/api'
|
||||
import {
|
||||
deployDomain,
|
||||
listDomains,
|
||||
removeDomain,
|
||||
setDomainActive,
|
||||
@@ -51,6 +63,31 @@ function daysLeftClass(expiresAt: string | null) {
|
||||
return styles.daysOk
|
||||
}
|
||||
|
||||
function formatDeployAt(iso: string | null) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleString(undefined, {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function deployStatusLabel(status: DomainListItem['lastDeployStatus']) {
|
||||
if (status === 'started') return 'Started'
|
||||
if (status === 'failed') return 'Failed'
|
||||
return null
|
||||
}
|
||||
|
||||
function deployStatusClass(status: DomainListItem['lastDeployStatus']) {
|
||||
if (status === 'started') return styles.deployOk
|
||||
if (status === 'failed') return styles.deployFail
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function WebsitesPage() {
|
||||
const { showToast } = useToast()
|
||||
const [data, setData] = useState<DomainsListResponse | null>(null)
|
||||
@@ -70,6 +107,7 @@ export function WebsitesPage() {
|
||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||
const [togglingSslId, setTogglingSslId] = useState<number | null>(null)
|
||||
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
@@ -229,6 +267,59 @@ export function WebsitesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeploy(domain: DomainListItem) {
|
||||
if (!domain.deploySlug) return
|
||||
if (deployingId != null) return
|
||||
|
||||
// Paint spinner + disabled state before the network call starts
|
||||
flushSync(() => {
|
||||
setDeployingId(domain.id)
|
||||
})
|
||||
setError('')
|
||||
showToast(`Deploying "${domain.host}"…`, 'info')
|
||||
|
||||
try {
|
||||
const result = await deployDomain(domain.id)
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domain.id
|
||||
? {
|
||||
...item,
|
||||
lastDeployedAt: result.lastDeployedAt,
|
||||
lastDeployStatus: result.lastDeployStatus,
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(`Deploy started for "${result.host}".`, 'success')
|
||||
} catch (err) {
|
||||
const message = err instanceof ApiError ? err.message : 'Deploy failed to start.'
|
||||
setError(message)
|
||||
setData((prev) => {
|
||||
if (!prev) return prev
|
||||
return {
|
||||
...prev,
|
||||
items: prev.items.map((item) =>
|
||||
item.id === domain.id
|
||||
? {
|
||||
...item,
|
||||
lastDeployedAt: new Date().toISOString(),
|
||||
lastDeployStatus: 'failed',
|
||||
}
|
||||
: item,
|
||||
),
|
||||
}
|
||||
})
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
setDeployingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove() {
|
||||
if (!removeTarget) return
|
||||
setError('')
|
||||
@@ -330,13 +421,14 @@ export function WebsitesPage() {
|
||||
<th className={tableStyles.th}>Owner business</th>
|
||||
<th className={tableStyles.th}>Days to expire</th>
|
||||
<th className={tableStyles.th}>SSL</th>
|
||||
<th className={tableStyles.th}>Latest deploy</th>
|
||||
<th className={`${tableStyles.th} ${tableStyles.thActions}`}>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
Loading...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -344,7 +436,7 @@ export function WebsitesPage() {
|
||||
|
||||
{!loading && data?.items?.length === 0 && (
|
||||
<tr>
|
||||
<td className={tableStyles.td} colSpan={5}>
|
||||
<td className={tableStyles.td} colSpan={6}>
|
||||
No results found.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -366,19 +458,49 @@ export function WebsitesPage() {
|
||||
<td className={tableStyles.td}>
|
||||
<div className={tableStyles.domainCell}>
|
||||
{domain.sslEnabled ? (
|
||||
<Lock size={16} className={tableStyles.sslOk} aria-label="SSL enabled" />
|
||||
<span
|
||||
className={tableStyles.sslIcon}
|
||||
title="SSL enabled"
|
||||
aria-label="SSL enabled"
|
||||
>
|
||||
<Lock size={16} className={tableStyles.sslOk} aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className={tableStyles.sslWarn}
|
||||
<span
|
||||
className={tableStyles.sslIcon}
|
||||
title="SSL not enabled"
|
||||
aria-label="SSL not enabled"
|
||||
/>
|
||||
>
|
||||
<AlertTriangle
|
||||
size={16}
|
||||
className={tableStyles.sslWarn}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<span className={tableStyles.subText}>
|
||||
{domain.sslEnabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className={tableStyles.td}>
|
||||
{domain.lastDeployedAt ? (
|
||||
<div className={styles.deployCell}>
|
||||
<span className={tableStyles.subText}>
|
||||
{formatDeployAt(domain.lastDeployedAt)}
|
||||
</span>
|
||||
{deployStatusLabel(domain.lastDeployStatus) ? (
|
||||
<span
|
||||
className={`${styles.deployStatus} ${deployStatusClass(domain.lastDeployStatus) ?? ''}`}
|
||||
>
|
||||
{deployStatusLabel(domain.lastDeployStatus)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<span className={tableStyles.subText}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`${tableStyles.td} ${tableStyles.tdActions}`}>
|
||||
<div className={tableStyles.rowActions}>
|
||||
<span className={tableStyles.toggleInActions}>
|
||||
@@ -389,6 +511,32 @@ export function WebsitesPage() {
|
||||
onChange={(isActive) => void handleToggleActive(domain, isActive)}
|
||||
/>
|
||||
</span>
|
||||
{domain.deploySlug ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${tableStyles.controlBtn} ${
|
||||
deployingId === domain.id ? styles.deployBusy : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (deployingId != null) return
|
||||
void handleDeploy(domain)
|
||||
}}
|
||||
disabled={deployingId === domain.id}
|
||||
title={
|
||||
deployingId === domain.id ? 'Deploying…' : 'Deploy website'
|
||||
}
|
||||
aria-label={
|
||||
deployingId === domain.id ? 'Deploying' : 'Deploy website'
|
||||
}
|
||||
aria-busy={deployingId === domain.id}
|
||||
>
|
||||
{deployingId === domain.id ? (
|
||||
<Loader2 size={16} className={styles.spin} aria-hidden />
|
||||
) : (
|
||||
<Rocket size={16} />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={tableStyles.controlBtn}
|
||||
|
||||
Reference in New Issue
Block a user