feat: remove nginx, add projects/milestones/notifications pages

- Dockerfile: replace nginx with serve for static files
- Fix auth endpoint: /auth/login → /auth/token
- Add ProjectsPage, ProjectDetailPage
- Add MilestonesPage, MilestoneDetailPage with progress bar
- Add NotificationsPage with unread count
- Sidebar: add milestones/notifications nav, live unread badge
- API: configurable VITE_API_BASE for host nginx proxy
- Types: add Milestone, MilestoneProgress, Notification, ProjectMember
This commit is contained in:
zhi
2026-03-06 13:05:19 +00:00
parent 853594f447
commit 54d4c4379a
13 changed files with 529 additions and 25 deletions

View File

@@ -0,0 +1,69 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/services/api'
import type { Notification } from '@/types'
import dayjs from 'dayjs'
export default function NotificationsPage() {
const [notifications, setNotifications] = useState<Notification[]>([])
const [unreadOnly, setUnreadOnly] = useState(false)
const [unreadCount, setUnreadCount] = useState(0)
const navigate = useNavigate()
const fetchNotifications = () => {
const params = new URLSearchParams()
if (unreadOnly) params.set('unread_only', 'true')
api.get<Notification[]>(`/notifications?${params}`).then(({ data }) => setNotifications(data))
api.get<{ count: number }>('/notifications/count').then(({ data }) => setUnreadCount(data.count)).catch(() => {})
}
useEffect(() => { fetchNotifications() }, [unreadOnly])
const markRead = async (id: number) => {
await api.post(`/notifications/${id}/read`)
fetchNotifications()
}
const markAllRead = async () => {
await api.post('/notifications/read-all')
fetchNotifications()
}
return (
<div className="notifications-page">
<div className="page-header">
<h2>🔔 {unreadCount > 0 && <span className="badge" style={{ background: 'var(--danger)' }}>{unreadCount}</span>}</h2>
{unreadCount > 0 && (
<button className="btn-primary" onClick={markAllRead}></button>
)}
</div>
<div className="filters">
<label className="filter-check">
<input type="checkbox" checked={unreadOnly} onChange={(e) => setUnreadOnly(e.target.checked)} />
</label>
</div>
<div className="notification-list">
{notifications.map((n) => (
<div
key={n.id}
className={`notification-item ${n.is_read ? 'read' : 'unread'}`}
onClick={() => {
if (!n.is_read) markRead(n.id)
if (n.issue_id) navigate(`/issues/${n.issue_id}`)
}}
>
<div className="notification-dot">{n.is_read ? '' : '●'}</div>
<div className="notification-body">
<p>{n.message}</p>
<span className="text-dim">{dayjs(n.created_at).format('MM-DD HH:mm')}</span>
</div>
</div>
))}
{notifications.length === 0 && <p className="empty"></p>}
</div>
</div>
)
}