Add portfolio website link field and card open-link control.
Stay on the current list page after move-to-top; only toast success. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
85524544ca
commit
c289de5d2e
@@ -1,8 +1,11 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { ChevronUp, Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
import { ChevronUp, Link2, Pencil, MessageSquare, Trash2 } from 'lucide-react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import type { Portfolio } from '../types/portfolio'
|
import type { Portfolio } from '../types/portfolio'
|
||||||
import { formatPortfolioCardDate } from '../services/portfolioService'
|
import {
|
||||||
|
formatPortfolioCardDate,
|
||||||
|
resolvePortfolioExternalUrl,
|
||||||
|
} from '../services/portfolioService'
|
||||||
import { useT } from '../i18n/useT'
|
import { useT } from '../i18n/useT'
|
||||||
import { textLocaleAttrs } from '../utils/textLocale'
|
import { textLocaleAttrs } from '../utils/textLocale'
|
||||||
import { Tooltip } from './Tooltip'
|
import { Tooltip } from './Tooltip'
|
||||||
@@ -45,6 +48,7 @@ export function PortfolioCard({
|
|||||||
const hasAbstract = Boolean(portfolio.abstract?.trim())
|
const hasAbstract = Boolean(portfolio.abstract?.trim())
|
||||||
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
|
// In FA UI, keep cards RTL even when EN titles/abstracts are shown.
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
|
||||||
|
const externalUrl = resolvePortfolioExternalUrl(portfolio.projectUrl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -129,6 +133,18 @@ export function PortfolioCard({
|
|||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
{externalUrl ? (
|
||||||
|
<Tooltip label={externalUrl}>
|
||||||
|
<a
|
||||||
|
href={externalUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={t('portfolio.card.openLink')}
|
||||||
|
>
|
||||||
|
<Link2 size={16} />
|
||||||
|
</a>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
<Tooltip label={t('portfolio.card.comments')}>
|
<Tooltip label={t('portfolio.card.comments')}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -181,6 +181,19 @@
|
|||||||
transition: background 0.2s, color 0.2s;
|
transition: background 0.2s, color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls a {
|
||||||
|
position: relative;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
.iconBtn {
|
.iconBtn {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -217,6 +230,11 @@
|
|||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.controls a:hover {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.controls button:disabled {
|
.controls button:disabled {
|
||||||
opacity: 0.35;
|
opacity: 0.35;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|||||||
@@ -1396,6 +1396,7 @@ const en = {
|
|||||||
|
|
||||||
'portfolio.card.moveUp': 'Move to top',
|
'portfolio.card.moveUp': 'Move to top',
|
||||||
'portfolio.card.edit': 'Edit portfolio',
|
'portfolio.card.edit': 'Edit portfolio',
|
||||||
|
'portfolio.card.openLink': 'Open website link',
|
||||||
'portfolio.card.comments': 'View comments',
|
'portfolio.card.comments': 'View comments',
|
||||||
'portfolio.card.remove': 'Remove portfolio',
|
'portfolio.card.remove': 'Remove portfolio',
|
||||||
'portfolio.card.noSummary': 'No summary yet.',
|
'portfolio.card.noSummary': 'No summary yet.',
|
||||||
@@ -1439,6 +1440,8 @@ const en = {
|
|||||||
'portfolio.form.titleEnPlaceholder': 'English title',
|
'portfolio.form.titleEnPlaceholder': 'English title',
|
||||||
'portfolio.form.abstract': 'Abstract',
|
'portfolio.form.abstract': 'Abstract',
|
||||||
'portfolio.form.abstractPlaceholder': 'Short summary shown in portfolio listings',
|
'portfolio.form.abstractPlaceholder': 'Short summary shown in portfolio listings',
|
||||||
|
'portfolio.form.link': 'Website link',
|
||||||
|
'portfolio.form.linkPlaceholder': 'https://example.com',
|
||||||
'portfolio.form.mainText': 'Main Text',
|
'portfolio.form.mainText': 'Main Text',
|
||||||
'portfolio.form.mainTextPlaceholder': 'Full portfolio content with formatting and images...',
|
'portfolio.form.mainTextPlaceholder': 'Full portfolio content with formatting and images...',
|
||||||
'portfolio.form.gallery': 'Image Gallery',
|
'portfolio.form.gallery': 'Image Gallery',
|
||||||
@@ -3576,6 +3579,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
|
|
||||||
'portfolio.card.moveUp': 'انتقال به ابتدا',
|
'portfolio.card.moveUp': 'انتقال به ابتدا',
|
||||||
'portfolio.card.edit': 'ویرایش نمونه کار',
|
'portfolio.card.edit': 'ویرایش نمونه کار',
|
||||||
|
'portfolio.card.openLink': 'باز کردن لینک وبسایت',
|
||||||
'portfolio.card.comments': 'مشاهده نظرات',
|
'portfolio.card.comments': 'مشاهده نظرات',
|
||||||
'portfolio.card.remove': 'حذف نمونه کار',
|
'portfolio.card.remove': 'حذف نمونه کار',
|
||||||
'portfolio.card.noSummary': 'هنوز خلاصهای نیست.',
|
'portfolio.card.noSummary': 'هنوز خلاصهای نیست.',
|
||||||
@@ -3619,6 +3623,8 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'portfolio.form.titleEnPlaceholder': 'English title',
|
'portfolio.form.titleEnPlaceholder': 'English title',
|
||||||
'portfolio.form.abstract': 'خلاصه',
|
'portfolio.form.abstract': 'خلاصه',
|
||||||
'portfolio.form.abstractPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست نمونه کارها',
|
'portfolio.form.abstractPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست نمونه کارها',
|
||||||
|
'portfolio.form.link': 'لینک وبسایت',
|
||||||
|
'portfolio.form.linkPlaceholder': 'https://example.com',
|
||||||
'portfolio.form.mainText': 'متن اصلی',
|
'portfolio.form.mainText': 'متن اصلی',
|
||||||
'portfolio.form.mainTextPlaceholder': 'محتوای کامل نمونه کار با قالببندی و تصاویر...',
|
'portfolio.form.mainTextPlaceholder': 'محتوای کامل نمونه کار با قالببندی و تصاویر...',
|
||||||
'portfolio.form.gallery': 'گالری تصاویر',
|
'portfolio.form.gallery': 'گالری تصاویر',
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function AddNewPortfolioPage() {
|
|||||||
const [titleFa, setTitleFa] = useState('')
|
const [titleFa, setTitleFa] = useState('')
|
||||||
const [titleEn, setTitleEn] = useState('')
|
const [titleEn, setTitleEn] = useState('')
|
||||||
const [abstract, setAbstract] = useState('')
|
const [abstract, setAbstract] = useState('')
|
||||||
|
const [projectUrl, setProjectUrl] = useState('')
|
||||||
const [mainTextHtml, setMainTextHtml] = useState('')
|
const [mainTextHtml, setMainTextHtml] = useState('')
|
||||||
const [titleImage, setTitleImage] = useState<string | null>(null)
|
const [titleImage, setTitleImage] = useState<string | null>(null)
|
||||||
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
|
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
|
||||||
@@ -103,6 +104,7 @@ export function AddNewPortfolioPage() {
|
|||||||
setTitleFa(form.titleFa)
|
setTitleFa(form.titleFa)
|
||||||
setTitleEn(form.titleEn)
|
setTitleEn(form.titleEn)
|
||||||
setAbstract(form.abstract)
|
setAbstract(form.abstract)
|
||||||
|
setProjectUrl(form.projectUrl)
|
||||||
setMainTextHtml(form.mainTextHtml)
|
setMainTextHtml(form.mainTextHtml)
|
||||||
setTitleImage(form.titleImage)
|
setTitleImage(form.titleImage)
|
||||||
setImages(form.images)
|
setImages(form.images)
|
||||||
@@ -146,6 +148,7 @@ export function AddNewPortfolioPage() {
|
|||||||
titleFa: titleFa.trim(),
|
titleFa: titleFa.trim(),
|
||||||
titleEn: titleEn.trim() || undefined,
|
titleEn: titleEn.trim() || undefined,
|
||||||
abstract: abstract.trim(),
|
abstract: abstract.trim(),
|
||||||
|
projectUrl: projectUrl.trim() || undefined,
|
||||||
mainTextHtml: resolvedMainTextHtml,
|
mainTextHtml: resolvedMainTextHtml,
|
||||||
categoryId: categoryId || undefined,
|
categoryId: categoryId || undefined,
|
||||||
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
||||||
@@ -159,6 +162,7 @@ export function AddNewPortfolioPage() {
|
|||||||
...payload,
|
...payload,
|
||||||
categoryId: categoryId || null,
|
categoryId: categoryId || null,
|
||||||
featuredMediaId: nextFeaturedMediaId,
|
featuredMediaId: nextFeaturedMediaId,
|
||||||
|
projectUrl: projectUrl.trim() || null,
|
||||||
})
|
})
|
||||||
showToast(t('portfolio.form.toast.updated'), 'success')
|
showToast(t('portfolio.form.toast.updated'), 'success')
|
||||||
} else {
|
} else {
|
||||||
@@ -319,6 +323,22 @@ export function AddNewPortfolioPage() {
|
|||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label htmlFor="portfolio-link">{t('portfolio.form.link')}</label>
|
||||||
|
<input
|
||||||
|
id="portfolio-link"
|
||||||
|
name="projectUrl"
|
||||||
|
type="url"
|
||||||
|
dir="ltr"
|
||||||
|
lang="en"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder={t('portfolio.form.linkPlaceholder')}
|
||||||
|
value={projectUrl}
|
||||||
|
onChange={(e) => setProjectUrl(e.target.value)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`${styles.field} ${styles.col12}`}>
|
<div className={`${styles.field} ${styles.col12}`}>
|
||||||
|
|||||||
@@ -182,11 +182,7 @@ export function PortfolioListPage() {
|
|||||||
try {
|
try {
|
||||||
await updatePortfolio(id, { moveToTop: true })
|
await updatePortfolio(id, { moveToTop: true })
|
||||||
showToast(t('portfolio.list.toast.movedUp'), 'success')
|
showToast(t('portfolio.list.toast.movedUp'), 'success')
|
||||||
// Absolute top lives on page 1 — jump there so the move is visible.
|
await loadPortfolios(currentPage)
|
||||||
writeListQuery(appliedName, appliedCategoryId, 1)
|
|
||||||
if (currentPage === 1) {
|
|
||||||
await loadPortfolios(1)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface PortfolioApi {
|
|||||||
titleEn: string | null
|
titleEn: string | null
|
||||||
slug: string
|
slug: string
|
||||||
abstract: string
|
abstract: string
|
||||||
|
projectUrl: string | null
|
||||||
mainTextHtml: string
|
mainTextHtml: string
|
||||||
status: PortfolioStatus
|
status: PortfolioStatus
|
||||||
categoryId: string | null
|
categoryId: string | null
|
||||||
@@ -58,6 +59,7 @@ export interface CreatePortfolioPayload {
|
|||||||
/** @deprecated Prefer titleFa */
|
/** @deprecated Prefer titleFa */
|
||||||
title?: string
|
title?: string
|
||||||
abstract?: string
|
abstract?: string
|
||||||
|
projectUrl?: string
|
||||||
mainTextHtml?: string
|
mainTextHtml?: string
|
||||||
categoryId?: string
|
categoryId?: string
|
||||||
status?: PortfolioStatus
|
status?: PortfolioStatus
|
||||||
@@ -73,6 +75,7 @@ export interface UpdatePortfolioPayload {
|
|||||||
/** @deprecated Prefer titleFa */
|
/** @deprecated Prefer titleFa */
|
||||||
title?: string
|
title?: string
|
||||||
abstract?: string | null
|
abstract?: string | null
|
||||||
|
projectUrl?: string | null
|
||||||
mainTextHtml?: string | null
|
mainTextHtml?: string | null
|
||||||
categoryId?: string | null
|
categoryId?: string | null
|
||||||
status?: PortfolioStatus
|
status?: PortfolioStatus
|
||||||
@@ -94,6 +97,13 @@ export function resolvePortfolioTitleImageUrl(portfolio: PortfolioApi): string |
|
|||||||
return extractFirstImageUrl(portfolio.mainTextHtml) || null
|
return extractFirstImageUrl(portfolio.mainTextHtml) || null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolvePortfolioExternalUrl(url: string | null | undefined): string | null {
|
||||||
|
const trimmed = url?.trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
if (/^https?:\/\//i.test(trimmed)) return trimmed
|
||||||
|
return `https://${trimmed}`
|
||||||
|
}
|
||||||
|
|
||||||
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
|
export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
|
||||||
return {
|
return {
|
||||||
id: portfolio.id,
|
id: portfolio.id,
|
||||||
@@ -102,6 +112,7 @@ export function mapPortfolioApiToUi(portfolio: PortfolioApi): Portfolio {
|
|||||||
titleEn: portfolio.titleEn ?? null,
|
titleEn: portfolio.titleEn ?? null,
|
||||||
slug: portfolio.slug,
|
slug: portfolio.slug,
|
||||||
abstract: portfolio.abstract,
|
abstract: portfolio.abstract,
|
||||||
|
projectUrl: portfolio.projectUrl?.trim() || null,
|
||||||
status: portfolio.status,
|
status: portfolio.status,
|
||||||
categoryId: portfolio.categoryId,
|
categoryId: portfolio.categoryId,
|
||||||
categoryName: portfolio.categoryName,
|
categoryName: portfolio.categoryName,
|
||||||
@@ -157,6 +168,7 @@ export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
|
|||||||
titleEn: portfolio.titleEn ?? '',
|
titleEn: portfolio.titleEn ?? '',
|
||||||
abstract: portfolio.abstract,
|
abstract: portfolio.abstract,
|
||||||
mainTextHtml: portfolio.mainTextHtml,
|
mainTextHtml: portfolio.mainTextHtml,
|
||||||
|
projectUrl: portfolio.projectUrl?.trim() || '',
|
||||||
categoryId: portfolio.categoryId ?? '',
|
categoryId: portfolio.categoryId ?? '',
|
||||||
tags: portfolio.tags,
|
tags: portfolio.tags,
|
||||||
titleImage: resolvePortfolioTitleImageUrl(portfolio),
|
titleImage: resolvePortfolioTitleImageUrl(portfolio),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface Portfolio {
|
|||||||
titleEn: string | null
|
titleEn: string | null
|
||||||
slug: string
|
slug: string
|
||||||
abstract: string
|
abstract: string
|
||||||
|
projectUrl: string | null
|
||||||
status: PortfolioStatus
|
status: PortfolioStatus
|
||||||
categoryId: string | null
|
categoryId: string | null
|
||||||
categoryName: string
|
categoryName: string
|
||||||
|
|||||||
Reference in New Issue
Block a user