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:
Alireza Hassani
2026-08-23 16:46:13 +03:30
co-authored by Cursor
parent 85524544ca
commit c289de5d2e
7 changed files with 76 additions and 7 deletions
+18 -2
View File
@@ -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;
+6
View File
@@ -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),
+1
View File
@@ -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