Fix portfolio list sorting UX and add name/category filters.
Newest portfolios show first with corrected move-up, category no longer mixes into the date, and website link generation can filter by tags. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2477936bce
commit
1e9f6481b0
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { AlertTriangle, X } from 'lucide-react'
|
||||
import { useT } from '../i18n/useT'
|
||||
import styles from './ConfirmDeleteModal.module.css'
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
@@ -20,6 +21,7 @@ export function ConfirmDeleteModal({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDeleteModalProps) {
|
||||
const t = useT()
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
|
||||
@@ -71,14 +73,14 @@ export function ConfirmDeleteModal({
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button type="button" className={styles.cancelBtn} onClick={onCancel}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button type="button" className={styles.deleteBtn} onClick={onConfirm}>
|
||||
Delete
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className={styles.closeBtn} onClick={onCancel} aria-label="Close">
|
||||
<button className={styles.closeBtn} onClick={onCancel} aria-label={t('common.close')}>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -51,6 +51,13 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
|
||||
@@ -13,14 +13,17 @@ import type { ProductCategory } from '../types/category'
|
||||
import {
|
||||
WEBSITE_LINK_KINDS,
|
||||
buildWebsiteCategoryUrl,
|
||||
buildWebsiteTagsUrl,
|
||||
type WebsiteLinkFilter,
|
||||
type WebsiteLinkKind,
|
||||
} from '../utils/websiteLinks'
|
||||
import { CategoryTreeSelect } from './CategoryTreeSelect'
|
||||
import { TagInput } from './TagInput'
|
||||
import modalStyles from './CategoryModal.module.css'
|
||||
import styles from './GenerateWebsiteLinkModal.module.css'
|
||||
|
||||
export type { WebsiteLinkKind }
|
||||
export { buildWebsiteCategoryUrl }
|
||||
export { buildWebsiteCategoryUrl, buildWebsiteTagsUrl }
|
||||
|
||||
const KIND_LABEL: Record<WebsiteLinkKind, BusinessMessageKey> = {
|
||||
product: 'website.linkGenerator.types.product',
|
||||
@@ -91,8 +94,10 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
const [mounted, setMounted] = useState(open)
|
||||
const [closing, setClosing] = useState(false)
|
||||
const [kind, setKind] = useState<WebsiteLinkKind>('product')
|
||||
const [filterBy, setFilterBy] = useState<WebsiteLinkFilter>('category')
|
||||
const [categories, setCategories] = useState<ProductCategory[]>([])
|
||||
const [categoryId, setCategoryId] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
@@ -101,8 +106,10 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
setMounted(true)
|
||||
setClosing(false)
|
||||
setKind('product')
|
||||
setFilterBy('category')
|
||||
setCategories([])
|
||||
setCategoryId('')
|
||||
setTags([])
|
||||
setError('')
|
||||
} else if (mounted) {
|
||||
setClosing(true)
|
||||
@@ -127,7 +134,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
}, [mounted, closing, onClose])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
if (!open || filterBy !== 'category') return
|
||||
|
||||
const controller = new AbortController()
|
||||
setLoading(true)
|
||||
@@ -152,7 +159,7 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [open, kind, t])
|
||||
}, [open, kind, filterBy, t])
|
||||
|
||||
const uiCategories = useMemo(
|
||||
() => categories.map(mapProductCategoryToUi),
|
||||
@@ -160,7 +167,12 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
)
|
||||
|
||||
const selected = categories.find((category) => category.id === categoryId)
|
||||
const preview = selected ? buildWebsiteCategoryUrl(kind, selected) : ''
|
||||
const preview =
|
||||
filterBy === 'category'
|
||||
? selected
|
||||
? buildWebsiteCategoryUrl(kind, selected)
|
||||
: ''
|
||||
: buildWebsiteTagsUrl(kind, tags)
|
||||
|
||||
if (!mounted) return null
|
||||
|
||||
@@ -171,6 +183,16 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
onPick(preview)
|
||||
}
|
||||
|
||||
function handleFilterChange(next: WebsiteLinkFilter) {
|
||||
setFilterBy(next)
|
||||
setError('')
|
||||
if (next === 'tag') {
|
||||
setCategoryId('')
|
||||
} else {
|
||||
setTags([])
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`${modalStyles.overlay} ${modalStyles.overlayStacked} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
|
||||
@@ -222,23 +244,47 @@ export function GenerateWebsiteLinkModal({ open, onClose, onPick }: GenerateWebs
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={`${modalStyles.field} ${styles.treeField}`}>
|
||||
<label htmlFor="generate-link-category">{t('website.linkGenerator.category')}</label>
|
||||
{loading ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.loading')}</p>
|
||||
) : uiCategories.length === 0 ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.empty')}</p>
|
||||
) : (
|
||||
<CategoryTreeSelect
|
||||
id="generate-link-category"
|
||||
categories={uiCategories}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('website.linkGenerator.pickCategory')}
|
||||
/>
|
||||
)}
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="generate-link-filter">{t('website.linkGenerator.filterBy')}</label>
|
||||
<select
|
||||
id="generate-link-filter"
|
||||
value={filterBy}
|
||||
onChange={(e) => handleFilterChange(e.target.value as WebsiteLinkFilter)}
|
||||
>
|
||||
<option value="category">{t('website.linkGenerator.category')}</option>
|
||||
<option value="tag">{t('website.linkGenerator.tag')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{filterBy === 'category' ? (
|
||||
<div className={`${modalStyles.field} ${styles.treeField}`}>
|
||||
<label htmlFor="generate-link-category">{t('website.linkGenerator.category')}</label>
|
||||
{loading ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.loading')}</p>
|
||||
) : uiCategories.length === 0 ? (
|
||||
<p className={styles.status}>{t('website.linkGenerator.empty')}</p>
|
||||
) : (
|
||||
<CategoryTreeSelect
|
||||
id="generate-link-category"
|
||||
categories={uiCategories}
|
||||
value={categoryId}
|
||||
onChange={setCategoryId}
|
||||
placeholder={t('website.linkGenerator.pickCategory')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={modalStyles.field}>
|
||||
<label htmlFor="generate-link-tags">{t('website.linkGenerator.tags')}</label>
|
||||
<TagInput
|
||||
tags={tags}
|
||||
onChange={setTags}
|
||||
placeholder={t('website.linkGenerator.tagsPlaceholder')}
|
||||
/>
|
||||
<p className={styles.hint}>{t('website.linkGenerator.tagsHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{preview ? (
|
||||
<p className={styles.preview} dir="ltr">
|
||||
{preview}
|
||||
|
||||
@@ -115,6 +115,24 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.categoryChip {
|
||||
display: inline-block;
|
||||
align-self: flex-start;
|
||||
max-width: 100%;
|
||||
padding: 3px 10px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(148, 163, 184, 0.15);
|
||||
border-radius: 50px;
|
||||
unicode-bidi: isolate;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
@@ -122,6 +140,6 @@
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metaDot {
|
||||
margin: 0 6px;
|
||||
.metaDate {
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
@@ -94,14 +94,15 @@ export function PortfolioCard({
|
||||
</p>
|
||||
)
|
||||
) : null}
|
||||
{portfolio.categoryName ? (
|
||||
<span className={cardStyles.categoryChip} dir="auto">
|
||||
{portfolio.categoryName}
|
||||
</span>
|
||||
) : null}
|
||||
<p className={cardStyles.meta}>
|
||||
{portfolio.categoryName ? (
|
||||
<>
|
||||
<span>{portfolio.categoryName}</span>
|
||||
<span className={cardStyles.metaDot}>·</span>
|
||||
</>
|
||||
) : null}
|
||||
<span>{publishDate}</span>
|
||||
<span className={cardStyles.metaDate} dir="auto">
|
||||
{publishDate}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user