Modernize frontend and centralize branding
This commit is contained in:
1 parent
bc4542d0fb
commit
2d61d80550
55 files changed
+1901
-14446
No files matched your search
@@ -0,0 +1,232 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar, Tag, ChevronRight, X, User } from "lucide-react";
|
||||
import { colors } from "../../theme/colors";
|
||||
import { API_BASE_URL } from '../../config/api';
|
||||
import branding from '../../branding';
|
||||
|
||||
const AnnouncementModal = ({ announcement, onClose }) => {
|
||||
const dialogRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
const previousFocus = document.activeElement;
|
||||
const handleKeyDown = (event) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
if (event.key !== 'Tab' || !dialogRef.current) return;
|
||||
const controls = dialogRef.current.querySelectorAll('button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
|
||||
if (!controls.length) return;
|
||||
const first = controls[0];
|
||||
const last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocus?.focus?.();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
if (!announcement) return null;
|
||||
|
||||
const getTagColor = (tag) => {
|
||||
const lowerTag = tag.toLowerCase();
|
||||
if (lowerTag.includes('update') || lowerTag.includes('feature')) return colors.green;
|
||||
if (lowerTag.includes('alert') || lowerTag.includes('maintenance') || lowerTag.includes('downtime')) return colors.red;
|
||||
if (lowerTag.includes('technical') || lowerTag.includes('info')) return colors.blue;
|
||||
return colors.yellow;
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="announcement-overlay">
|
||||
<div ref={dialogRef} className="announcement-dialog" role="dialog" aria-modal="true" aria-labelledby="announcement-title" tabIndex={-1}>
|
||||
{/* Header */}
|
||||
<div className="announcement-dialog-header">
|
||||
<div className="flex-1 pr-8">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{announcement.tags && announcement.tags.map((tag, idx) => {
|
||||
const tagColor = getTagColor(tag);
|
||||
return (
|
||||
<span
|
||||
key={idx}
|
||||
className="announcement-tag"
|
||||
style={{ backgroundColor: `${tagColor}20`, color: tagColor, border: `1px solid ${tagColor}30` }}
|
||||
>
|
||||
<Tag className="w-3 h-3 mr-1.5" />
|
||||
{tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<h2 id="announcement-title" className="text-2xl sm:text-3xl font-semibold leading-tight">
|
||||
{announcement.title}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="announcement-close"
|
||||
aria-label="Close announcement"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="announcement-dialog-content">
|
||||
<div className="announcement-meta">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-2" aria-hidden />
|
||||
{formatDate(announcement.created_at)}
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<User className="w-4 h-4 mr-2" aria-hidden />
|
||||
Posted by <span className="font-semibold ml-1.5">{announcement.author_name || 'Administrator'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="announcement-body"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
>
|
||||
{announcement.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="announcement-dialog-footer">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="site-button site-button--primary"
|
||||
>
|
||||
Close Announcement
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
};
|
||||
|
||||
const AnnouncementsSection = () => {
|
||||
const [announcements, setAnnouncements] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchAnnouncements = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/announcements?limit=20`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch announcements');
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
setAnnouncements(data.announcements);
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching announcements:', err);
|
||||
setError('Could not load latest news.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAnnouncements();
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
} catch (e) {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const displayedAnnouncements = showAll ? announcements : announcements.slice(0, 3);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="site-section site-announcements" aria-busy="true" aria-label="Loading announcements">
|
||||
<div className="site-section-heading"><div><p className="site-kicker">Updates</p><h2>Latest announcements.</h2></div></div>
|
||||
<div className="site-announcement-list">
|
||||
{[1, 2, 3].map((item) => <div key={item} className="site-announcement-skeleton" />)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || announcements.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section id="announcements" className="site-section site-announcements">
|
||||
<div className="site-section-heading">
|
||||
<div><p className="site-kicker">Platform updates</p><h2>Latest announcements.</h2></div>
|
||||
<p>Service changes and notes from the {branding.name} team.</p>
|
||||
</div>
|
||||
<div className="site-announcement-list">
|
||||
{displayedAnnouncements.map((item) => (
|
||||
<button key={item.id} type="button" className="site-announcement-item" onClick={() => setSelectedAnnouncement(item)}>
|
||||
<span className="site-announcement-meta">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
{item.tags?.[0] && <span className="site-announcement-tag">{item.tags[0]}</span>}
|
||||
</span>
|
||||
<span className="site-announcement-title">{item.title}</span>
|
||||
<span className="site-announcement-summary">{item.content}</span>
|
||||
<span className="site-announcement-open">Read announcement <ChevronRight size={16} aria-hidden /></span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{announcements.length > 3 && (
|
||||
<button type="button" className="site-button site-button--quiet site-announcement-more" onClick={() => setShowAll((value) => !value)}>
|
||||
{showAll ? 'Show fewer updates' : `Show all ${announcements.length} updates`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Modal Overlay */}
|
||||
{selectedAnnouncement && (
|
||||
<AnnouncementModal
|
||||
announcement={selectedAnnouncement}
|
||||
onClose={() => setSelectedAnnouncement(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default AnnouncementsSection;
|
||||
Reference in new issue
Block a user