feat: enhance news management with create modal and detail page
This commit is contained in:
parent
fb623b26a2
commit
55bfead4a8
@ -16,6 +16,8 @@ export default function NewsManagementPage() {
|
||||
const [showCreate, setShowCreate] = React.useState(false)
|
||||
const [selected, setSelected] = React.useState<any | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<any | null>(null)
|
||||
const [createError, setCreateError] = React.useState<string | null>(null)
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
@ -71,7 +73,24 @@ export default function NewsManagementPage() {
|
||||
<Footer />
|
||||
|
||||
{showCreate && (
|
||||
<CreateNewsModal onClose={() => setShowCreate(false)} onCreate={async (payload) => { await addNews(payload); setShowCreate(false); await refresh() }} />
|
||||
<CreateNewsModal
|
||||
onClose={() => { setShowCreate(false); setCreateError(null) }}
|
||||
creating={creating}
|
||||
error={createError}
|
||||
onCreate={async (payload) => {
|
||||
setCreateError(null)
|
||||
setCreating(true)
|
||||
try {
|
||||
await addNews(payload)
|
||||
setShowCreate(false)
|
||||
await refresh()
|
||||
} catch (e: any) {
|
||||
setCreateError(e?.message || 'Failed to create news')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
||||
@ -107,11 +126,22 @@ export default function NewsManagementPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void }) {
|
||||
function CreateNewsModal({
|
||||
onClose,
|
||||
onCreate,
|
||||
creating,
|
||||
error,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) => void
|
||||
creating: boolean
|
||||
error: string | null
|
||||
}) {
|
||||
const [title, setTitle] = React.useState('')
|
||||
const [summary, setSummary] = React.useState('')
|
||||
const [content, setContent] = React.useState('')
|
||||
const [slug, setSlug] = React.useState('')
|
||||
const [slugTouched, setSlugTouched] = React.useState(false)
|
||||
const [category, setCategory] = React.useState('')
|
||||
const [isActive, setIsActive] = React.useState(true)
|
||||
const [publishedAt, setPublishedAt] = React.useState<string>('')
|
||||
@ -143,9 +173,24 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
||||
setRawUrl(null)
|
||||
}
|
||||
|
||||
const slugify = (value: string) => value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slugTouched) {
|
||||
setSlug(slugify(title))
|
||||
}
|
||||
}, [title, slugTouched])
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate({ title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
||||
const safeSlug = slug.trim() || slugify(title)
|
||||
onCreate({ title: title.trim(), summary: summary || undefined, content: content || undefined, slug: safeSlug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
||||
}
|
||||
|
||||
return (
|
||||
@ -158,8 +203,22 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700"><XMarkIcon className="h-6 w-6"/></button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||
<div>
|
||||
<input
|
||||
className="w-full border rounded px-4 py-2 text-gray-900"
|
||||
placeholder="Slug"
|
||||
value={slug}
|
||||
onChange={e=>{ setSlugTouched(true); setSlug(e.target.value) }}
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Used in the URL. Auto-generated from title unless edited.</p>
|
||||
</div>
|
||||
<input className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||
<textarea className="w-full border rounded px-4 py-2 text-gray-900" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||
@ -188,7 +247,13 @@ function CreateNewsModal({ onClose, onCreate }: { onClose: () => void; onCreate:
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 pt-4 border-t">
|
||||
<button type="button" onClick={onClose} className="px-5 py-2.5 text-sm bg-red-50 text-red-700 rounded-lg hover:bg-red-100">Cancel</button>
|
||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={creating || !title.trim()}
|
||||
className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{creating ? 'Creating…' : 'Add News'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
151
src/app/news/[slug]/page.tsx
Normal file
151
src/app/news/[slug]/page.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import Header from '../../components/nav/Header'
|
||||
import Footer from '../../components/Footer'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
|
||||
type PublicNewsItem = {
|
||||
id: number
|
||||
title: string
|
||||
summary?: string
|
||||
content?: string
|
||||
slug: string
|
||||
category?: string
|
||||
imageUrl?: string
|
||||
published_at?: string | null
|
||||
}
|
||||
|
||||
function estimateReadingTime(text: string) {
|
||||
const words = text.trim().split(/\s+/).filter(Boolean).length
|
||||
const minutes = Math.max(1, Math.ceil(words / 200))
|
||||
return `${minutes} min read`
|
||||
}
|
||||
|
||||
export default function NewsDetailPage() {
|
||||
const params = useParams()
|
||||
const slug = typeof params?.slug === 'string' ? params.slug : Array.isArray(params?.slug) ? params.slug[0] : ''
|
||||
|
||||
const [item, setItem] = React.useState<PublicNewsItem | null>(null)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) return
|
||||
let active = true
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const res = await fetch(`${BASE_URL}/api/news/${encodeURIComponent(slug)}`)
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error('News article not found.')
|
||||
throw new Error('Failed to load news article.')
|
||||
}
|
||||
const json = await res.json()
|
||||
if (active) setItem(json.data || null)
|
||||
} catch (e: any) {
|
||||
if (active) setError(e?.message || 'Failed to load news article.')
|
||||
} finally {
|
||||
if (active) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => { active = false }
|
||||
}, [slug])
|
||||
|
||||
const readingTime = item?.content ? estimateReadingTime(item.content) : null
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<Header />
|
||||
<main className="bg-gradient-to-b from-blue-50 to-white min-h-screen">
|
||||
<div className="mx-auto max-w-6xl px-6 py-10">
|
||||
<nav className="text-sm text-gray-500 mb-6">
|
||||
<Link href="/" className="hover:text-blue-900">Home</Link>
|
||||
<span className="mx-2">/</span>
|
||||
<Link href="/news" className="hover:text-blue-900">News</Link>
|
||||
{item?.title && (
|
||||
<>
|
||||
<span className="mx-2">/</span>
|
||||
<span className="text-gray-700 line-clamp-1">{item.title}</span>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{loading && (
|
||||
<div className="rounded-2xl bg-white shadow-lg border border-gray-100 p-8">
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-6 w-2/3 bg-gray-200 rounded" />
|
||||
<div className="h-4 w-1/3 bg-gray-100 rounded" />
|
||||
<div className="h-64 w-full bg-gray-100 rounded" />
|
||||
<div className="h-4 w-3/4 bg-gray-100 rounded" />
|
||||
<div className="h-4 w-5/6 bg-gray-100 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<div className="rounded-2xl bg-white shadow-lg border border-red-100 p-8">
|
||||
<div className="text-red-700 font-medium mb-2">{error}</div>
|
||||
<Link href="/news" className="text-blue-900 hover:text-blue-700 font-semibold">
|
||||
Back to News
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && item && (
|
||||
<article className="rounded-2xl bg-white shadow-lg border border-gray-100 overflow-hidden">
|
||||
<header className="p-6 sm:p-10">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-blue-700">
|
||||
{item.category && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-100 px-2.5 py-1 font-medium">
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{item.published_at && (
|
||||
<span className="text-gray-500">
|
||||
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
{readingTime && (
|
||||
<span className="text-gray-500">• {readingTime}</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl sm:text-4xl font-extrabold text-blue-900 tracking-tight">
|
||||
{item.title}
|
||||
</h1>
|
||||
{item.summary && (
|
||||
<p className="mt-4 text-lg text-gray-700">
|
||||
{item.summary}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="px-6 sm:px-10 pb-10">
|
||||
<div className="mx-auto max-w-[104ch]">
|
||||
{item.imageUrl ? (
|
||||
<div className="mb-6 lg:-mt-24 lg:mb-2 lg:ml-16 lg:float-right lg:w-[60%] rounded-2xl overflow-hidden shadow bg-gray-50 border border-gray-100">
|
||||
<img
|
||||
src={item.imageUrl}
|
||||
alt={item.title}
|
||||
className="w-full h-72 sm:h-80 lg:h-96 object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{item.content || 'No content available.'}
|
||||
</div>
|
||||
<div className="clear-both" />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import Header from '../components/nav/Header'
|
||||
import Footer from '../components/Footer'
|
||||
import PageTransitionEffect from '../components/animation/pageTransitionEffect'
|
||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type PublicNewsItem = {
|
||||
id: number
|
||||
@ -17,47 +17,10 @@ type PublicNewsItem = {
|
||||
published_at?: string | null
|
||||
}
|
||||
|
||||
function NewsDetailModal({ item, onClose }: { item: PublicNewsItem; onClose: () => void }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/60" onClick={onClose} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="relative w-full max-w-4xl bg-white rounded-2xl shadow-2xl max-h-[90vh] flex flex-col">
|
||||
<div className="flex-shrink-0 bg-white border-b px-6 py-4 flex items-center justify-between rounded-t-2xl">
|
||||
<h2 className="text-2xl font-bold text-blue-900 pr-4">{item.title}</h2>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 flex-shrink-0">
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-6 overflow-y-auto">
|
||||
{item.imageUrl && (
|
||||
<div className="rounded-xl overflow-hidden mb-6 max-h-80 flex items-center justify-center bg-gray-50">
|
||||
<img src={item.imageUrl} alt={item.title} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
)}
|
||||
{item.published_at && (
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
Published: {new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
{item.summary && (
|
||||
<div className="text-lg text-gray-700 mb-6 font-medium">{item.summary}</div>
|
||||
)}
|
||||
<div className="prose prose-lg max-w-none text-gray-800 whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{item.content || 'No content available.'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function NewsPage() {
|
||||
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
const [selectedItem, setSelectedItem] = React.useState<PublicNewsItem | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
@ -110,10 +73,10 @@ export default function NewsPage() {
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{items.map(item => (
|
||||
<article
|
||||
<Link
|
||||
key={item.id}
|
||||
onClick={() => setSelectedItem(item)}
|
||||
className="bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-300 cursor-pointer transform hover:-translate-y-1"
|
||||
href={`/news/${item.slug}`}
|
||||
className="group bg-white rounded-2xl shadow-lg overflow-hidden hover:shadow-2xl transition-all duration-300 transform hover:-translate-y-1"
|
||||
>
|
||||
<div className="aspect-[3/2] bg-gradient-to-br from-blue-100 to-blue-50">
|
||||
{item.imageUrl ? (
|
||||
@ -125,35 +88,38 @@ export default function NewsPage() {
|
||||
)}
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<h2 className="text-xl font-bold text-blue-900 mb-3 line-clamp-2 hover:text-blue-700">
|
||||
<div className="flex items-center gap-2 text-xs text-blue-700">
|
||||
{item.category && (
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-100 px-2 py-0.5 font-medium">
|
||||
{item.category}
|
||||
</span>
|
||||
)}
|
||||
{item.published_at && (
|
||||
<span className="text-gray-500">
|
||||
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-3 text-xl font-bold text-blue-900 line-clamp-2 group-hover:text-blue-700">
|
||||
{item.title}
|
||||
</h2>
|
||||
{item.summary && (
|
||||
<p className="text-gray-700 mb-4 line-clamp-3">{item.summary}</p>
|
||||
<p className="mt-3 text-gray-700 line-clamp-3">{item.summary}</p>
|
||||
)}
|
||||
{item.published_at && (
|
||||
<div className="text-sm text-gray-500 mb-4">
|
||||
{new Date(item.published_at).toLocaleDateString('de-DE')}
|
||||
</div>
|
||||
)}
|
||||
<button className="inline-flex items-center text-blue-900 font-semibold hover:text-blue-700">
|
||||
<div className="mt-4 inline-flex items-center text-blue-900 font-semibold group-hover:text-blue-700">
|
||||
Read More
|
||||
<svg className="ml-2 h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{selectedItem && (
|
||||
<NewsDetailModal item={selectedItem} onClose={() => setSelectedItem(null)} />
|
||||
)}
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user