feat: implement calendar invitation system with creation, acceptance, and management features
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m26s
This commit is contained in:
@@ -514,6 +514,50 @@ export async function initDatabase() {
|
|||||||
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
await db.exec('ALTER TABLE calendar_events ADD COLUMN federated_join_url TEXT DEFAULT NULL');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Calendar invitations (federated calendar events that must be accepted first)
|
||||||
|
if (isPostgres) {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
event_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_time TIMESTAMP NOT NULL,
|
||||||
|
end_time TIMESTAMP NOT NULL,
|
||||||
|
room_uid TEXT,
|
||||||
|
join_url TEXT,
|
||||||
|
from_user TEXT NOT NULL,
|
||||||
|
to_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
color TEXT DEFAULT '#6366f1',
|
||||||
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_cal_inv_uid_user ON calendar_invitations(event_uid, to_user_id);
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
|
await db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS calendar_invitations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
event_uid TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
start_time DATETIME NOT NULL,
|
||||||
|
end_time DATETIME NOT NULL,
|
||||||
|
room_uid TEXT,
|
||||||
|
join_url TEXT,
|
||||||
|
from_user TEXT NOT NULL,
|
||||||
|
to_user_id INTEGER NOT NULL,
|
||||||
|
color TEXT DEFAULT '#6366f1',
|
||||||
|
status TEXT DEFAULT 'pending' CHECK(status IN ('pending','accepted','declined')),
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (to_user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
UNIQUE(event_uid, to_user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cal_inv_to_user ON calendar_invitations(to_user_id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Default admin (only on very first start) ────────────────────────────
|
// ── Default admin (only on very first start) ────────────────────────────
|
||||||
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
const adminAlreadySeeded = await db.get("SELECT value FROM settings WHERE key = 'admin_seeded'");
|
||||||
if (!adminAlreadySeeded) {
|
if (!adminAlreadySeeded) {
|
||||||
|
|||||||
@@ -413,13 +413,13 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
|
|||||||
const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
|
const targetUser = await db.get('SELECT id, name, email FROM users WHERE LOWER(name) = LOWER(?)', [username]);
|
||||||
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
|
if (!targetUser) return res.status(404).json({ error: 'User not found on this instance' });
|
||||||
|
|
||||||
// Check duplicate
|
// Check duplicate (already in invitations or already accepted into calendar)
|
||||||
const existing = await db.get('SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?', [event_uid, targetUser.id]);
|
const existingInv = await db.get('SELECT id FROM calendar_invitations WHERE event_uid = ? AND to_user_id = ?', [event_uid, targetUser.id]);
|
||||||
if (existing) return res.json({ success: true, message: 'Event already received' });
|
if (existingInv) return res.json({ success: true, message: 'Calendar invitation already received' });
|
||||||
|
|
||||||
// Create event for the target user
|
// Store as pending invitation — user must accept before it appears in calendar
|
||||||
await db.run(`
|
await db.run(`
|
||||||
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
|
INSERT INTO calendar_invitations (event_uid, title, description, start_time, end_time, room_uid, join_url, from_user, to_user_id, color)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, [
|
`, [
|
||||||
event_uid,
|
event_uid,
|
||||||
@@ -428,10 +428,10 @@ router.post(['/receive-event', '/calendar-event'], calendarFederationLimiter, as
|
|||||||
start_time,
|
start_time,
|
||||||
end_time,
|
end_time,
|
||||||
room_uid || null,
|
room_uid || null,
|
||||||
|
join_url || null,
|
||||||
|
from_user,
|
||||||
targetUser.id,
|
targetUser.id,
|
||||||
'#6366f1',
|
'#6366f1',
|
||||||
from_user,
|
|
||||||
join_url || null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@@ -261,12 +261,20 @@ router.get('/invitations', authenticateToken, async (req, res) => {
|
|||||||
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
|
router.get('/invitations/pending-count', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const result = await db.get(
|
const roomResult = await db.get(
|
||||||
`SELECT COUNT(*) as count FROM federation_invitations
|
`SELECT COUNT(*) as count FROM federation_invitations
|
||||||
WHERE to_user_id = ? AND status = 'pending'`,
|
WHERE to_user_id = ? AND status = 'pending'`,
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
res.json({ count: result?.count || 0 });
|
let calResult = { count: 0 };
|
||||||
|
try {
|
||||||
|
calResult = await db.get(
|
||||||
|
`SELECT COUNT(*) as count FROM calendar_invitations
|
||||||
|
WHERE to_user_id = ? AND status = 'pending'`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
} catch { /* table may not exist yet */ }
|
||||||
|
res.json({ count: (roomResult?.count || 0) + (calResult?.count || 0) });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.json({ count: 0 });
|
res.json({ count: 0 });
|
||||||
}
|
}
|
||||||
@@ -338,6 +346,94 @@ router.delete('/invitations/:id', authenticateToken, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GET /api/federation/calendar-invitations — List calendar invitations ─────
|
||||||
|
router.get('/calendar-invitations', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const invitations = await db.all(
|
||||||
|
`SELECT * FROM calendar_invitations
|
||||||
|
WHERE to_user_id = ?
|
||||||
|
ORDER BY created_at DESC`,
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ invitations });
|
||||||
|
} catch (err) {
|
||||||
|
log.federation.error('List calendar invitations error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to load calendar invitations' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /api/federation/calendar-invitations/:id/accept ─────────────────────
|
||||||
|
router.post('/calendar-invitations/:id/accept', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const inv = await db.get(
|
||||||
|
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||||
|
if (inv.status === 'accepted') return res.status(400).json({ error: 'Already accepted' });
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
`UPDATE calendar_invitations SET status = 'accepted' WHERE id = ?`,
|
||||||
|
[inv.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if event was already previously accepted (duplicate guard)
|
||||||
|
const existing = await db.get(
|
||||||
|
'SELECT id FROM calendar_events WHERE uid = ? AND user_id = ?',
|
||||||
|
[inv.event_uid, req.user.id]
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
await db.run(`
|
||||||
|
INSERT INTO calendar_events (uid, title, description, start_time, end_time, room_uid, user_id, color, federated_from, federated_join_url)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, [
|
||||||
|
inv.event_uid,
|
||||||
|
inv.title,
|
||||||
|
inv.description || null,
|
||||||
|
inv.start_time,
|
||||||
|
inv.end_time,
|
||||||
|
inv.room_uid || null,
|
||||||
|
req.user.id,
|
||||||
|
inv.color || '#6366f1',
|
||||||
|
inv.from_user,
|
||||||
|
inv.join_url || null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.federation.error('Accept calendar invitation error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to accept calendar invitation' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── DELETE /api/federation/calendar-invitations/:id — Decline/dismiss ────────
|
||||||
|
router.delete('/calendar-invitations/:id', authenticateToken, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const db = getDb();
|
||||||
|
const inv = await db.get(
|
||||||
|
`SELECT * FROM calendar_invitations WHERE id = ? AND to_user_id = ?`,
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!inv) return res.status(404).json({ error: 'Calendar invitation not found' });
|
||||||
|
|
||||||
|
if (inv.status === 'pending') {
|
||||||
|
// mark as declined
|
||||||
|
await db.run(`UPDATE calendar_invitations SET status = 'declined' WHERE id = ?`, [inv.id]);
|
||||||
|
} else {
|
||||||
|
// accepted or declined — permanently remove from inbox
|
||||||
|
await db.run('DELETE FROM calendar_invitations WHERE id = ?', [inv.id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
log.federation.error('Delete calendar invitation error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to remove calendar invitation' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
|
// ── GET /api/federation/federated-rooms — List saved federated rooms ────────
|
||||||
router.get('/federated-rooms', authenticateToken, async (req, res) => {
|
router.get('/federated-rooms', authenticateToken, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -408,7 +408,11 @@
|
|||||||
"roomDetails": "Raumdetails",
|
"roomDetails": "Raumdetails",
|
||||||
"joinUrl": "Beitritts-URL",
|
"joinUrl": "Beitritts-URL",
|
||||||
"roomDeleted": "Gelöscht",
|
"roomDeleted": "Gelöscht",
|
||||||
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
|
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar.",
|
||||||
|
"calendarEvent": "Kalendereinladung",
|
||||||
|
"calendarAccepted": "Kalender-Event angenommen und in deinen Kalender eingetragen!",
|
||||||
|
"invitationRemoved": "Einladung entfernt",
|
||||||
|
"removeInvitation": "Einladung entfernen"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Kalender",
|
"title": "Kalender",
|
||||||
@@ -456,9 +460,9 @@
|
|||||||
"shareFailed": "Event konnte nicht geteilt werden",
|
"shareFailed": "Event konnte nicht geteilt werden",
|
||||||
"sendFederated": "An Remote senden",
|
"sendFederated": "An Remote senden",
|
||||||
"sendFederatedTitle": "Event an Remote-Instanz senden",
|
"sendFederatedTitle": "Event an Remote-Instanz senden",
|
||||||
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz.",
|
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz. Der Empfänger muss die Einladung zuerst annehmen, bevor das Event in seinem Kalender erscheint.",
|
||||||
"send": "Senden",
|
"send": "Senden",
|
||||||
"fedSent": "Event an Remote-Instanz gesendet!",
|
"fedSent": "Kalendereinladung gesendet! Der Empfänger muss diese zuerst annehmen.",
|
||||||
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
|
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
|
||||||
"openRoom": "Verknüpften Raum öffnen",
|
"openRoom": "Verknüpften Raum öffnen",
|
||||||
"organizer": "Organisator",
|
"organizer": "Organisator",
|
||||||
|
|||||||
@@ -408,7 +408,11 @@
|
|||||||
"roomDetails": "Room Details",
|
"roomDetails": "Room Details",
|
||||||
"joinUrl": "Join URL",
|
"joinUrl": "Join URL",
|
||||||
"roomDeleted": "Deleted",
|
"roomDeleted": "Deleted",
|
||||||
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
|
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available.",
|
||||||
|
"calendarEvent": "Calendar Invitation",
|
||||||
|
"calendarAccepted": "Calendar event accepted and added to your calendar!",
|
||||||
|
"invitationRemoved": "Invitation removed",
|
||||||
|
"removeInvitation": "Remove invitation"
|
||||||
},
|
},
|
||||||
"calendar": {
|
"calendar": {
|
||||||
"title": "Calendar",
|
"title": "Calendar",
|
||||||
@@ -456,9 +460,9 @@
|
|||||||
"shareFailed": "Could not share event",
|
"shareFailed": "Could not share event",
|
||||||
"sendFederated": "Send to remote",
|
"sendFederated": "Send to remote",
|
||||||
"sendFederatedTitle": "Send Event to Remote Instance",
|
"sendFederatedTitle": "Send Event to Remote Instance",
|
||||||
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance.",
|
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance. The recipient must accept the invitation before the event appears in their calendar.",
|
||||||
"send": "Send",
|
"send": "Send",
|
||||||
"fedSent": "Event sent to remote instance!",
|
"fedSent": "Calendar invitation sent! The recipient must accept it first.",
|
||||||
"fedFailed": "Could not send event to remote instance",
|
"fedFailed": "Could not send event to remote instance",
|
||||||
"openRoom": "Open linked room",
|
"openRoom": "Open linked room",
|
||||||
"organizer": "Organizer",
|
"organizer": "Organizer",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
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 api from '../services/api';
|
||||||
import { useLanguage } from '../contexts/LanguageContext';
|
import { useLanguage } from '../contexts/LanguageContext';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
@@ -7,12 +7,17 @@ import toast from 'react-hot-toast';
|
|||||||
export default function FederationInbox() {
|
export default function FederationInbox() {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const [invitations, setInvitations] = useState([]);
|
const [invitations, setInvitations] = useState([]);
|
||||||
|
const [calendarInvitations, setCalendarInvitations] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
const fetchInvitations = async () => {
|
const fetchInvitations = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.get('/federation/invitations');
|
const [roomRes, calRes] = await Promise.all([
|
||||||
setInvitations(res.data.invitations || []);
|
api.get('/federation/invitations'),
|
||||||
|
api.get('/federation/calendar-invitations').catch(() => ({ data: { invitations: [] } })),
|
||||||
|
]);
|
||||||
|
setInvitations(roomRes.data.invitations || []);
|
||||||
|
setCalendarInvitations(calRes.data.invitations || []);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('federation.loadFailed'));
|
toast.error(t('federation.loadFailed'));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -24,6 +29,7 @@ export default function FederationInbox() {
|
|||||||
fetchInvitations();
|
fetchInvitations();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Room invitation actions ──────────────────────────────────────────────
|
||||||
const handleAccept = async (id) => {
|
const handleAccept = async (id) => {
|
||||||
try {
|
try {
|
||||||
await api.post(`/federation/invitations/${id}/accept`);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-20">
|
<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 pendingRooms = invitations.filter(i => i.status === 'pending');
|
||||||
const past = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -67,14 +119,15 @@ export default function FederationInbox() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pending invitations */}
|
{/* Pending invitations */}
|
||||||
{pending.length > 0 && (
|
{totalPending > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||||
{t('federation.pending')} ({pending.length})
|
{t('federation.pending')} ({totalPending})
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{pending.map(inv => (
|
{/* Pending room invitations */}
|
||||||
<div key={inv.id} className="card p-5 border-l-4 border-l-th-accent">
|
{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="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
@@ -92,17 +145,50 @@ export default function FederationInbox() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
<div className="flex items-center gap-2 flex-shrink-0">
|
||||||
<button
|
<button onClick={() => handleAccept(inv.id)} className="btn-primary text-sm">
|
||||||
onClick={() => handleAccept(inv.id)}
|
|
||||||
className="btn-primary text-sm"
|
|
||||||
>
|
|
||||||
<Check size={16} />
|
<Check size={16} />
|
||||||
{t('federation.accept')}
|
{t('federation.accept')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button onClick={() => handleDecline(inv.id)} className="btn-secondary text-sm">
|
||||||
onClick={() => handleDecline(inv.id)}
|
<X size={16} />
|
||||||
className="btn-secondary text-sm"
|
{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} />
|
<X size={16} />
|
||||||
{t('federation.decline')}
|
{t('federation.decline')}
|
||||||
</button>
|
</button>
|
||||||
@@ -115,20 +201,24 @@ export default function FederationInbox() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Past invitations */}
|
{/* Past invitations */}
|
||||||
{past.length > 0 && (
|
{totalPast > 0 && (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
<h2 className="text-sm font-semibold text-th-text uppercase tracking-wider mb-4">
|
||||||
{t('federation.previousInvites')}
|
{t('federation.previousInvites')}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{past.map(inv => (
|
{/* Past room invitations */}
|
||||||
<div key={inv.id} className="card p-4 opacity-60">
|
{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="flex items-center justify-between gap-4">
|
||||||
|
<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">
|
<div className="min-w-0">
|
||||||
<h3 className="text-sm font-medium text-th-text truncate">{inv.room_name}</h3>
|
<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>
|
<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>
|
||||||
|
<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'
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${inv.status === 'accepted'
|
||||||
? 'bg-th-success/15 text-th-success'
|
? 'bg-th-success/15 text-th-success'
|
||||||
: 'bg-th-error/15 text-th-error'
|
: 'bg-th-error/15 text-th-error'
|
||||||
@@ -144,6 +234,52 @@ export default function FederationInbox() {
|
|||||||
<ExternalLink size={14} />
|
<ExternalLink size={14} />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,7 +289,7 @@ export default function FederationInbox() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Empty state */}
|
{/* Empty state */}
|
||||||
{invitations.length === 0 && (
|
{totalPending === 0 && totalPast === 0 && (
|
||||||
<div className="card p-12 text-center">
|
<div className="card p-12 text-center">
|
||||||
<Inbox size={48} className="mx-auto text-th-text-s/40 mb-4" />
|
<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>
|
<h3 className="text-lg font-semibold text-th-text mb-2">{t('federation.noInvitations')}</h3>
|
||||||
@@ -163,3 +299,4 @@ export default function FederationInbox() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user