feat: implement calendar invitation system with creation, acceptance, and management features
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s

This commit is contained in:
2026-03-02 13:57:56 +01:00
parent 62a3812424
commit d989e1291d
6 changed files with 328 additions and 43 deletions

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox } from 'lucide-react';
import { Globe, Mail, Check, X, ExternalLink, Loader2, Inbox, Calendar, Trash2 } from 'lucide-react';
import api from '../services/api';
import { useLanguage } from '../contexts/LanguageContext';
import toast from 'react-hot-toast';
@@ -7,12 +7,17 @@ import toast from 'react-hot-toast';
export default function FederationInbox() {
const { t } = useLanguage();
const [invitations, setInvitations] = useState([]);
const [calendarInvitations, setCalendarInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const fetchInvitations = async () => {
try {
const res = await api.get('/federation/invitations');
setInvitations(res.data.invitations || []);
const [roomRes, calRes] = await Promise.all([
api.get('/federation/invitations'),
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
]);
setInvitations(roomRes.data.invitations || []);
setCalendarInvitations(calRes.data.invitations || []);
} catch {
toast.error(t('federation.loadFailed'));
} finally {
@@ -24,6 +29,7 @@ export default function FederationInbox() {
fetchInvitations();
}, []);
// ── Room invitation actions ──────────────────────────────────────────────
const handleAccept = async (id) => {
try {
await api.post(`/federation/invitations/${id}/accept`);
@@ -44,6 +50,47 @@ export default function FederationInbox() {
}
};
const handleDeleteInvitation = async (id) => {
try {
await api.delete(`/federation/invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
// ── Calendar invitation actions ──────────────────────────────────────────
const handleCalAccept = async (id) => {
try {
await api.post(`/federation/calendar-invitations/${id}/accept`);
toast.success(t('federation.calendarAccepted'));
fetchInvitations();
} catch {
toast.error(t('federation.acceptFailed'));
}
};
const handleCalDecline = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.declined'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
const handleCalDelete = async (id) => {
try {
await api.delete(`/federation/calendar-invitations/${id}`);
toast.success(t('federation.invitationRemoved'));
fetchInvitations();
} catch {
toast.error(t('federation.declineFailed'));
}
};
if (loading) {
return (
<div className="flex items-center justify-center py-20">
@@ -52,8 +99,13 @@ export default function FederationInbox() {
);
}
const pending = invitations.filter(i => i.status === 'pending');
const past = invitations.filter(i => i.status !== 'pending');
const pendingRooms = invitations.filter(i => i.status === 'pending');
const pastRooms = invitations.filter(i => i.status !== 'pending');
const pendingCal = calendarInvitations.filter(i => i.status === 'pending');
const pastCal = calendarInvitations.filter(i => i.status !== 'pending');
const totalPending = pendingRooms.length + pendingCal.length;
const totalPast = pastRooms.length + pastCal.length;
return (
<div>
@@ -67,14 +119,15 @@ export default function FederationInbox() {
</div>
{/* Pending invitations */}
{pending.length > 0 && (
{totalPending > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.pending')} ({pending.length})
{t('federation.pending')} ({totalPending})
</h2>
<div className="space-y-3">
{pending.map(inv => (
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
{/* Pending room invitations */}
{pendingRooms.map(inv => (
<div key={`room-${inv.id}`} className="card p-5 border-l-4 border-l-th-accent">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
@@ -92,17 +145,50 @@ export default function FederationInbox() {
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={() => handleAccept(inv.id)}
className="btn-primary text-sm"
>
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button
onClick={() => handleDecline(inv.id)}
className="btn-secondary text-sm"
>
<button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
</div>
</div>
</div>
))}
{/* Pending calendar invitations */}
{pendingCal.map(inv => (
<div key={`cal-${inv.id}`} className="card p-5 border-l-4 border-l-th-success">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2 mb-1">
<Calendar size={16} className="text-th-success flex-shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wide text-th-success mr-1">
{t('federation.calendarEvent')}
</span>
<h3 className="text-base font-semibold text-th-text truncate">{inv.title}</h3>
</div>
<p className="text-sm text-th-text-s">
{t('federation.from')}: <span className="font-medium text-th-text">{inv.from_user}</span>
</p>
<p className="text-sm text-th-text-s mt-1">
{new Date(inv.start_time).toLocaleString()} {new Date(inv.end_time).toLocaleString()}
</p>
{inv.description && (
<p className="text-sm text-th-text-s mt-1 italic">"{inv.description}"</p>
)}
<p className="text-xs text-th-text-s mt-1">
{new Date(inv.created_at).toLocaleString()}
</p>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button onClick={() => handleCalAccept(inv.id)} className="btn-primary text-sm">
<Check size={16} />
{t('federation.accept')}
</button>
<button onClick={() => handleCalDecline(inv.id)} className="btn-secondary text-sm">
<X size={16} />
{t('federation.decline')}
</button>
@@ -115,24 +201,28 @@ export default function FederationInbox() {
)}
{/* Past invitations */}
{past.length > 0 && (
{totalPast > 0 && (
<div className="mb-8">
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
{t('federation.previousInvites')}
</h2>
<div className="space-y-2">
{past.map(inv => (
<div key={inv.id} className="card p-4 opacity-60">
{/* Past room invitations */}
{pastRooms.map(inv => (
<div key={`room-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
<div className="min-w-0 flex items-center gap-2">
<Mail size={14} className="text-th-text-s flex-shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
<p className="text-xs text-th-text-s">{inv.from_user}</p>
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && (
@@ -144,6 +234,52 @@ export default function FederationInbox() {
<ExternalLink size={14} />
</button>
)}
<button
onClick={() => handleDeleteInvitation(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
))}
{/* Past calendar invitations */}
{pastCal.map(inv => (
<div key={`cal-past-${inv.id}`} className="card p-4 opacity-70">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex items-center gap-2">
<Calendar size={14} className="text-th-text-s flex-shrink-0" />
<div className="min-w-0">
<h3 className="text-sm font-medium text-th-text truncate">{inv.title}</h3>
<p className="text-xs text-th-text-s">{inv.from_user} · {new Date(inv.start_time).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
? 'bg-th-success/15 text-th-success'
: 'bg-th-error/15 text-th-error'
}`}>
{inv.status === 'accepted' ? t('federation.statusAccepted') : t('federation.statusDeclined')}
</span>
{inv.status === 'accepted' && inv.join_url && (
<button
onClick={() => window.open(inv.join_url, '_blank')}
className="btn-ghost text-xs py-1.5 px-2"
title={t('federation.openLink')}
>
<ExternalLink size={14} />
</button>
)}
<button
onClick={() => handleCalDelete(inv.id)}
className="btn-ghost text-xs py-1.5 px-2 text-th-text-s hover:text-th-error"
title={t('federation.removeInvitation')}
>
<Trash2 size={14} />
</button>
</div>
</div>
</div>
@@ -153,7 +289,7 @@ export default function FederationInbox() {
)}
{/* Empty state */}
{invitations.length === 0 && (
{totalPending === 0 && totalPast === 0 && (
<div className="card p-12 text-center">
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
@@ -163,3 +299,4 @@ export default function FederationInbox() {
</div>
);
}