news - links missing in UI
This commit is contained in:
parent
1c87ba150e
commit
615c5e7e0b
27
src/app/admin/news-management/hooks/addNews.ts
Normal file
27
src/app/admin/news-management/hooks/addNews.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function addNews(payload: { title: string; summary?: string; content?: string; slug: string; category?: string; isActive: boolean; publishedAt?: string | null; imageFile?: File }) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const form = new FormData()
|
||||
form.append('title', payload.title)
|
||||
if (payload.summary) form.append('summary', payload.summary)
|
||||
if (payload.content) form.append('content', payload.content)
|
||||
form.append('slug', payload.slug)
|
||||
if (payload.category) form.append('category', payload.category)
|
||||
form.append('isActive', String(payload.isActive))
|
||||
if (payload.publishedAt) form.append('publishedAt', payload.publishedAt)
|
||||
if (payload.imageFile) form.append('image', payload.imageFile)
|
||||
|
||||
const url = `${BASE_URL}/api/admin/news`
|
||||
const res = await authFetch(url, { method: 'POST', body: form, headers: { Accept: 'application/json' } })
|
||||
let body: any = null
|
||||
try { body = await res.clone().json() } catch {}
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.debug('[addNews] status:', res.status)
|
||||
console.debug('[addNews] body preview:', body ? JSON.stringify(body).slice(0,500) : '<no body>')
|
||||
}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to create news (${res.status})`)
|
||||
return body || res.json()
|
||||
}
|
||||
9
src/app/admin/news-management/hooks/deleteNews.ts
Normal file
9
src/app/admin/news-management/hooks/deleteNews.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function deleteNews(id: number) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const url = `${BASE_URL}/api/admin/news/${id}`
|
||||
const res = await authFetch(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
|
||||
if (!res.ok) throw new Error('Failed to delete news')
|
||||
return res.json()
|
||||
}
|
||||
52
src/app/admin/news-management/hooks/getNews.ts
Normal file
52
src/app/admin/news-management/hooks/getNews.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export type AdminNewsItem = {
|
||||
id: number
|
||||
title: string
|
||||
summary?: string
|
||||
slug: string
|
||||
category?: string
|
||||
imageUrl?: string
|
||||
isActive: boolean
|
||||
publishedAt?: string | null
|
||||
}
|
||||
|
||||
export function useAdminNews() {
|
||||
const [items, setItems] = React.useState<AdminNewsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const refresh = React.useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const url = `${BASE_URL}/api/admin/news`
|
||||
const res = await authFetch(url, { headers: { Accept: 'application/json' } })
|
||||
let json: any = null
|
||||
try { json = await res.clone().json() } catch {}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(json?.error || json?.message || 'Failed to fetch admin news')
|
||||
const data = (json.data || []).map((r: any) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
summary: r.summary,
|
||||
slug: r.slug,
|
||||
category: r.category,
|
||||
imageUrl: r.imageUrl,
|
||||
isActive: !!r.is_active,
|
||||
publishedAt: r.published_at || null,
|
||||
}))
|
||||
setItems(data)
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => { refresh() }, [refresh])
|
||||
|
||||
return { items, loading, error, refresh }
|
||||
}
|
||||
24
src/app/admin/news-management/hooks/updateNews.ts
Normal file
24
src/app/admin/news-management/hooks/updateNews.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import React from 'react'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
export async function updateNews(id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) {
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const form = new FormData()
|
||||
if (payload.title) form.append('title', payload.title)
|
||||
if (payload.summary) form.append('summary', payload.summary)
|
||||
if (payload.content) form.append('content', payload.content)
|
||||
if (payload.slug) form.append('slug', payload.slug)
|
||||
if (payload.category) form.append('category', payload.category)
|
||||
if (payload.isActive !== undefined) form.append('isActive', String(payload.isActive))
|
||||
if (payload.publishedAt !== undefined && payload.publishedAt !== null) form.append('publishedAt', payload.publishedAt)
|
||||
if (payload.removeImage) form.append('removeImage', 'true')
|
||||
if (payload.imageFile) form.append('image', payload.imageFile)
|
||||
|
||||
const url = `${BASE_URL}/api/admin/news/${id}`
|
||||
const res = await authFetch(url, { method: 'PATCH', body: form, headers: { Accept: 'application/json' } })
|
||||
let body: any = null
|
||||
try { body = await res.clone().json() } catch {}
|
||||
if (res.status === 401) throw new Error('Unauthorized. Please log in.')
|
||||
if (res.status === 403) throw new Error('Forbidden. Admin access required.')
|
||||
if (!res.ok) throw new Error(body?.error || body?.message || `Failed to update news (${res.status})`)
|
||||
return body || res.json()
|
||||
}
|
||||
260
src/app/admin/news-management/page.tsx
Normal file
260
src/app/admin/news-management/page.tsx
Normal file
@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Header from '../../components/nav/Header'
|
||||
import Footer from '../../components/Footer'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import { PlusIcon, PencilIcon, TrashIcon, PhotoIcon, XMarkIcon } from '@heroicons/react/24/outline'
|
||||
import AffiliateCropModal from '../affiliate-management/components/AffiliateCropModal'
|
||||
import { useAdminNews } from './hooks/getNews'
|
||||
import { addNews } from './hooks/addNews'
|
||||
import { updateNews } from './hooks/updateNews'
|
||||
import { deleteNews } from './hooks/deleteNews'
|
||||
|
||||
export default function NewsManagementPage() {
|
||||
const { items, loading, error, refresh } = useAdminNews()
|
||||
const [showCreate, setShowCreate] = React.useState(false)
|
||||
const [selected, setSelected] = React.useState<any | null>(null)
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<Header />
|
||||
<main className="bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6 pt-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-blue-900">News Manager</h1>
|
||||
<button onClick={() => setShowCreate(true)} className="flex items-center gap-2 px-4 py-2 bg-blue-900 text-white rounded-lg hover:bg-blue-800">
|
||||
<PlusIcon className="h-5 w-5" /> Add News
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
<div key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
||||
<div className="aspect-[3/2] bg-gray-100">
|
||||
{item.imageUrl ? (
|
||||
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<PhotoIcon className="h-12 w-12 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-blue-900 truncate">{item.title}</h3>
|
||||
<span className={`text-xs px-2 py-1 rounded ${item.isActive ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-600'}`}>{item.isActive ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-2">{item.summary}</p>}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button onClick={() => setSelected(item)} className="px-3 py-1.5 text-sm bg-blue-50 text-blue-900 rounded hover:bg-blue-100">
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={async () => { await deleteNews(item.id); await refresh() }} className="px-3 py-1.5 text-sm bg-red-50 text-red-700 rounded hover:bg-red-100">
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
{showCreate && (
|
||||
<CreateNewsModal onClose={() => setShowCreate(false)} onCreate={async (payload) => { await addNews(payload); setShowCreate(false); await refresh() }} />
|
||||
)}
|
||||
{selected && (
|
||||
<EditNewsModal item={selected} onClose={() => setSelected(null)} onUpdate={async (id, payload) => { await updateNews(id, payload); setSelected(null); await refresh() }} />
|
||||
)}
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const [title, setTitle] = React.useState('')
|
||||
const [summary, setSummary] = React.useState('')
|
||||
const [content, setContent] = React.useState('')
|
||||
const [slug, setSlug] = React.useState('')
|
||||
const [category, setCategory] = React.useState('')
|
||||
const [isActive, setIsActive] = React.useState(true)
|
||||
const [publishedAt, setPublishedAt] = React.useState<string>('')
|
||||
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
|
||||
const [showCrop, setShowCrop] = React.useState(false)
|
||||
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
|
||||
|
||||
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
const allowed = ['image/jpeg','image/png','image/webp']
|
||||
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
|
||||
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
|
||||
const url = URL.createObjectURL(f)
|
||||
setRawUrl(url)
|
||||
setShowCrop(true)
|
||||
e.target.value=''
|
||||
}
|
||||
|
||||
const onCropComplete = (blob: Blob) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
if (rawUrl) URL.revokeObjectURL(rawUrl)
|
||||
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
|
||||
setImageFile(file)
|
||||
setPreviewUrl(URL.createObjectURL(blob))
|
||||
setRawUrl(null)
|
||||
}
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onCreate({ title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt: publishedAt || undefined, imageFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">Add News</h2>
|
||||
<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">
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('news-image-upload') as HTMLInputElement)?.click()}>
|
||||
{!previewUrl ? (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||
<img src={previewUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
|
||||
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input id="news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="isActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||
<label htmlFor="isActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||
<input type="datetime-local" value={publishedAt} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm" />
|
||||
</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-gray-100 rounded-lg">Cancel</button>
|
||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Add News</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function EditNewsModal({ item, onClose, onUpdate }: { item: any; onClose: () => void; onUpdate: (id: number, payload: { title?: string; summary?: string; content?: string; slug?: string; category?: string; isActive?: boolean; publishedAt?: string | null; imageFile?: File; removeImage?: boolean }) => void }) {
|
||||
const [title, setTitle] = React.useState(item.title)
|
||||
const [summary, setSummary] = React.useState(item.summary || '')
|
||||
const [content, setContent] = React.useState('')
|
||||
const [slug, setSlug] = React.useState(item.slug)
|
||||
const [category, setCategory] = React.useState(item.category || '')
|
||||
const [isActive, setIsActive] = React.useState(item.isActive)
|
||||
const [publishedAt, setPublishedAt] = React.useState<string>(item.publishedAt || '')
|
||||
const [imageFile, setImageFile] = React.useState<File | undefined>(undefined)
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null)
|
||||
const [currentUrl, setCurrentUrl] = React.useState<string | undefined>(item.imageUrl)
|
||||
const [removeImage, setRemoveImage] = React.useState(false)
|
||||
const [showCrop, setShowCrop] = React.useState(false)
|
||||
const [rawUrl, setRawUrl] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => () => { if (previewUrl) URL.revokeObjectURL(previewUrl); if (rawUrl) URL.revokeObjectURL(rawUrl) }, [previewUrl, rawUrl])
|
||||
|
||||
const openFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const f = e.target.files?.[0]
|
||||
if (!f) return
|
||||
const allowed = ['image/jpeg','image/png','image/webp']
|
||||
if (!allowed.includes(f.type)) { alert('Invalid image type'); e.target.value=''; return }
|
||||
if (f.size > 5*1024*1024) { alert('Max 5MB'); e.target.value=''; return }
|
||||
const url = URL.createObjectURL(f)
|
||||
setRawUrl(url)
|
||||
setShowCrop(true)
|
||||
e.target.value=''
|
||||
}
|
||||
|
||||
const onCropComplete = (blob: Blob) => {
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl)
|
||||
if (rawUrl) URL.revokeObjectURL(rawUrl)
|
||||
const file = new File([blob], 'news-image.jpg', { type: 'image/jpeg' })
|
||||
setImageFile(file)
|
||||
setRemoveImage(false)
|
||||
setPreviewUrl(URL.createObjectURL(blob))
|
||||
setRawUrl(null)
|
||||
}
|
||||
|
||||
const displayUrl = removeImage ? null : (previewUrl || currentUrl)
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onUpdate(item.id, { title, summary: summary || undefined, content: content || undefined, slug, category: category || undefined, isActive, publishedAt, imageFile, removeImage: removeImage && !imageFile })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AffiliateCropModal isOpen={showCrop} imageSrc={rawUrl || ''} onClose={() => { setShowCrop(false); if (rawUrl) URL.revokeObjectURL(rawUrl); setRawUrl(null) }} onCropComplete={onCropComplete} />
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="relative w-full max-w-2xl bg-white rounded-2xl shadow-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white border-b px-6 py-4 flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold text-blue-900">Edit News</h2>
|
||||
<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">
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Title" value={title} onChange={e=>setTitle(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Slug" value={slug} onChange={e=>setSlug(e.target.value)} required />
|
||||
<input className="w-full border rounded px-4 py-2" placeholder="Category" value={category} onChange={e=>setCategory(e.target.value)} />
|
||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Summary" value={summary} onChange={e=>setSummary(e.target.value)} rows={3} />
|
||||
<textarea className="w-full border rounded px-4 py-2" placeholder="Content (markdown/html)" value={content} onChange={e=>setContent(e.target.value)} rows={6} />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-2">Image</label>
|
||||
<div className="relative flex justify-center items-center rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 cursor-pointer overflow-hidden" style={{ minHeight:'200px' }} onClick={() => (document.getElementById('edit-news-image-upload') as HTMLInputElement)?.click()}>
|
||||
{!displayUrl ? (
|
||||
<div className="text-center w-full px-6 py-10">
|
||||
<PhotoIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<div className="mt-4 text-sm font-medium text-gray-700">Click to upload</div>
|
||||
<p className="text-xs text-gray-500 mt-2">PNG, JPG, WebP up to 5MB</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative w-full h-full min-h-[200px] flex items-center justify-center bg-white p-4">
|
||||
<img src={displayUrl} alt="Preview" className="max-h-[180px] max-w-full object-contain" />
|
||||
<button type="button" onClick={() => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPreviewUrl(null); setImageFile(undefined); setCurrentUrl(undefined); setRemoveImage(true) }} className="absolute top-2 right-2 bg-red-50 hover:bg-red-100 text-red-700 px-3 py-1.5 rounded-lg text-sm">Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input id="edit-news-image-upload" type="file" accept="image/*" className="hidden" onChange={openFile} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id="editIsActive" type="checkbox" checked={isActive} onChange={e=>setIsActive(e.target.checked)} className="h-4 w-4" />
|
||||
<label htmlFor="editIsActive" className="text-sm font-medium text-gray-700">Active</label>
|
||||
<input type="datetime-local" value={publishedAt || ''} onChange={e=>setPublishedAt(e.target.value)} className="ml-auto border rounded px-2 py-1 text-sm" />
|
||||
</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-gray-100 rounded-lg">Cancel</button>
|
||||
<button type="submit" className="px-5 py-2.5 text-sm text-white bg-blue-900 rounded-lg">Save Changes</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
74
src/app/news/page.tsx
Normal file
74
src/app/news/page.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
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
|
||||
slug: string
|
||||
category?: string
|
||||
imageUrl?: string
|
||||
published_at?: string | null
|
||||
}
|
||||
|
||||
export default function NewsPage() {
|
||||
const [items, setItems] = React.useState<PublicNewsItem[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/news/active')
|
||||
if (!res.ok) throw new Error('Failed to fetch news')
|
||||
const json = await res.json()
|
||||
const data = (json.data || [])
|
||||
setItems(data)
|
||||
} catch (e: any) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<Header />
|
||||
<main className="bg-white">
|
||||
<div className="mx-auto max-w-7xl px-6 pt-8">
|
||||
<h1 className="text-2xl font-bold text-blue-900">Latest News</h1>
|
||||
{loading && <div className="mt-4">Loading...</div>}
|
||||
{error && <div className="mt-4 text-red-600">{error}</div>}
|
||||
{!loading && !error && items.length === 0 && <div className="mt-4 text-gray-600">No news available.</div>}
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{items.map(item => (
|
||||
<article key={item.id} className="rounded-2xl border border-gray-200 overflow-hidden bg-white shadow-sm">
|
||||
<div className="aspect-[3/2] bg-gray-100">
|
||||
{item.imageUrl ? (
|
||||
<img src={item.imageUrl} alt={item.title} className="h-full w-full object-cover" />
|
||||
) : (
|
||||
<div className="h-full w-full flex items-center justify-center text-gray-400">No image</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h2 className="font-semibold text-blue-900">{item.title}</h2>
|
||||
{item.published_at && <div className="text-xs text-gray-500 mt-1">{new Date(item.published_at).toLocaleString()}</div>}
|
||||
{item.summary && <p className="mt-2 text-sm text-gray-700 line-clamp-3">{item.summary}</p>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</PageTransitionEffect>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user