301 lines
11 KiB
JavaScript
301 lines
11 KiB
JavaScript
import { useState, useEffect, useContext } from 'react'
|
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react'
|
|
import { AppContext } from '../App'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { api, PLATFORMS } from '../utils/api'
|
|
import PostDetailPanel from '../components/PostDetailPanel'
|
|
import { SkeletonCalendar } from '../components/SkeletonLoader'
|
|
|
|
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
|
|
const STATUS_COLORS = {
|
|
draft: 'bg-surface-tertiary text-text-secondary',
|
|
in_review: 'bg-amber-100 text-amber-700',
|
|
approved: 'bg-blue-100 text-blue-700',
|
|
scheduled: 'bg-purple-100 text-purple-700',
|
|
published: 'bg-emerald-100 text-emerald-700',
|
|
rejected: 'bg-red-100 text-red-700',
|
|
}
|
|
|
|
function getMonthData(year, month) {
|
|
const firstDay = new Date(year, month, 1).getDay()
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
|
const prevDays = new Date(year, month, 0).getDate()
|
|
|
|
const cells = []
|
|
// Previous month trailing days
|
|
for (let i = firstDay - 1; i >= 0; i--) {
|
|
cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) })
|
|
}
|
|
// Current month
|
|
for (let d = 1; d <= daysInMonth; d++) {
|
|
cells.push({ day: d, current: true, date: new Date(year, month, d) })
|
|
}
|
|
// Next month leading days
|
|
const remaining = 42 - cells.length
|
|
for (let d = 1; d <= remaining; d++) {
|
|
cells.push({ day: d, current: false, date: new Date(year, month + 1, d) })
|
|
}
|
|
return cells
|
|
}
|
|
|
|
function dateKey(d) {
|
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
}
|
|
|
|
export default function PostCalendar() {
|
|
const { t } = useLanguage()
|
|
const { brands } = useContext(AppContext)
|
|
const today = new Date()
|
|
const [year, setYear] = useState(today.getFullYear())
|
|
const [month, setMonth] = useState(today.getMonth())
|
|
const [posts, setPosts] = useState([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [filters, setFilters] = useState({ brand: '', platform: '', status: '' })
|
|
const [selectedPost, setSelectedPost] = useState(null)
|
|
|
|
useEffect(() => {
|
|
loadPosts()
|
|
}, [])
|
|
|
|
const loadPosts = async () => {
|
|
try {
|
|
const res = await api.get('/posts')
|
|
setPosts(res.data || res || [])
|
|
} catch (err) {
|
|
console.error('Failed to load posts:', err)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const cells = getMonthData(year, month)
|
|
const todayKey = dateKey(today)
|
|
|
|
// Filter posts
|
|
const filteredPosts = posts.filter(p => {
|
|
if (filters.brand && String(p.brand_id || p.brandId) !== filters.brand) return false
|
|
if (filters.platform) {
|
|
const platforms = p.platforms || (p.platform ? [p.platform] : [])
|
|
if (!platforms.includes(filters.platform)) return false
|
|
}
|
|
if (filters.status && p.status !== filters.status) return false
|
|
return true
|
|
})
|
|
|
|
// Group posts by date (use scheduled_date or published_date)
|
|
const postsByDate = {}
|
|
const unscheduled = []
|
|
for (const post of filteredPosts) {
|
|
const dateStr = post.scheduled_date || post.scheduledDate || post.published_date || post.publishedDate
|
|
if (dateStr) {
|
|
const key = dateStr.slice(0, 10) // yyyy-mm-dd
|
|
if (!postsByDate[key]) postsByDate[key] = []
|
|
postsByDate[key].push(post)
|
|
} else {
|
|
unscheduled.push(post)
|
|
}
|
|
}
|
|
|
|
const prevMonth = () => {
|
|
if (month === 0) { setMonth(11); setYear(y => y - 1) }
|
|
else setMonth(m => m - 1)
|
|
}
|
|
const nextMonth = () => {
|
|
if (month === 11) { setMonth(0); setYear(y => y + 1) }
|
|
else setMonth(m => m + 1)
|
|
}
|
|
const goToday = () => { setYear(today.getFullYear()); setMonth(today.getMonth()) }
|
|
|
|
const monthLabel = new Date(year, month).toLocaleString('default', { month: 'long', year: 'numeric' })
|
|
|
|
const handlePostClick = (post) => {
|
|
setSelectedPost(post)
|
|
}
|
|
|
|
const handlePanelClose = () => {
|
|
setSelectedPost(null)
|
|
loadPosts()
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<SkeletonCalendar />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4 animate-fade-in">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-text-primary">Content Calendar</h1>
|
|
<p className="text-sm text-text-secondary mt-1">Schedule and plan your posts</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<select
|
|
value={filters.brand}
|
|
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
|
>
|
|
<option value="">All Brands</option>
|
|
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
|
</select>
|
|
|
|
<select
|
|
value={filters.platform}
|
|
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
|
>
|
|
<option value="">All Platforms</option>
|
|
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
|
</select>
|
|
|
|
<select
|
|
value={filters.status}
|
|
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
|
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
|
>
|
|
<option value="">All Statuses</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="in_review">In Review</option>
|
|
<option value="approved">Approved</option>
|
|
<option value="scheduled">Scheduled</option>
|
|
<option value="published">Published</option>
|
|
<option value="rejected">Rejected</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Calendar */}
|
|
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
|
{/* Nav */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={prevMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<h3 className="text-lg font-semibold text-text-primary min-w-[180px] text-center">{monthLabel}</h3>
|
|
<button onClick={nextMonth} className="p-2 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors">
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
|
|
Today
|
|
</button>
|
|
</div>
|
|
|
|
{/* Day headers */}
|
|
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
|
|
{DAYS.map(d => (
|
|
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
|
|
{d}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Cells */}
|
|
<div className="grid grid-cols-7">
|
|
{cells.map((cell, i) => {
|
|
const key = dateKey(cell.date)
|
|
const isToday = key === todayKey
|
|
const dayPosts = postsByDate[key] || []
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`border-r border-b border-border min-h-[110px] p-2 ${
|
|
cell.current ? 'bg-surface' : 'bg-surface-secondary/30'
|
|
} ${i % 7 === 6 ? 'border-r-0' : ''}`}
|
|
>
|
|
<div className={`text-sm font-semibold mb-1 w-7 h-7 flex items-center justify-center rounded-full ${
|
|
isToday ? 'bg-brand-primary text-white' : cell.current ? 'text-text-primary' : 'text-text-tertiary'
|
|
}`}>
|
|
{cell.day}
|
|
</div>
|
|
<div className="space-y-1">
|
|
{dayPosts.slice(0, 3).map(post => (
|
|
<button
|
|
key={post.Id || post._id}
|
|
onClick={() => handlePostClick(post)}
|
|
className={`w-full text-left text-[10px] px-2 py-1 rounded font-medium hover:opacity-80 transition-opacity truncate ${
|
|
STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'
|
|
}`}
|
|
title={post.title}
|
|
>
|
|
{post.title}
|
|
</button>
|
|
))}
|
|
{dayPosts.length > 3 && (
|
|
<div className="text-[9px] text-text-tertiary text-center font-medium">
|
|
+{dayPosts.length - 3} more
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Unscheduled Posts */}
|
|
{unscheduled.length > 0 && (
|
|
<div className="bg-surface rounded-xl border border-border p-6">
|
|
<h3 className="text-sm font-semibold text-text-tertiary uppercase mb-3">Unscheduled Posts</h3>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
{unscheduled.map(post => (
|
|
<button
|
|
key={post.Id || post._id}
|
|
onClick={() => handlePostClick(post)}
|
|
className="text-left bg-surface-secondary border border-border rounded-lg p-3 hover:border-brand-primary/30 transition-colors"
|
|
>
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[post.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
|
{post.status}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm font-medium text-text-primary truncate">{post.title}</p>
|
|
{post.brand_name && (
|
|
<p className="text-xs text-text-tertiary mt-1">{post.brand_name}</p>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Legend */}
|
|
<div className="bg-surface rounded-xl border border-border p-4">
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">Status Legend</h4>
|
|
<div className="flex flex-wrap gap-3">
|
|
{Object.entries(STATUS_COLORS).map(([status, color]) => (
|
|
<div key={status} className="flex items-center gap-2">
|
|
<div className={`w-4 h-4 rounded ${color}`}></div>
|
|
<span className="text-xs text-text-secondary capitalize">{status.replace('_', ' ')}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Detail Panel */}
|
|
{selectedPost && (
|
|
<PostDetailPanel
|
|
post={selectedPost}
|
|
onClose={handlePanelClose}
|
|
onSave={async (postId, data) => {
|
|
await api.patch(`/posts/${postId}`, data)
|
|
handlePanelClose()
|
|
}}
|
|
onDelete={async (postId) => {
|
|
await api.delete(`/posts/${postId}`)
|
|
handlePanelClose()
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|