feat: add calendar component with event management features including create, edit, delete, and share functionalities
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m25s

This commit is contained in:
2026-03-02 10:35:01 +01:00
parent fae46c8395
commit 13c60ba052
17 changed files with 2210 additions and 2 deletions

View File

@@ -16,6 +16,7 @@ import Admin from './pages/Admin';
import GuestJoin from './pages/GuestJoin';
import FederationInbox from './pages/FederationInbox';
import FederatedRoomDetail from './pages/FederatedRoomDetail';
import Calendar from './pages/Calendar';
export default function App() {
const { user, loading } = useAuth();
@@ -54,6 +55,7 @@ export default function App() {
{/* Protected routes */}
<Route element={<ProtectedRoute><Layout /></ProtectedRoute>}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/rooms/:uid" element={<RoomDetail />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<Admin />} />

View File

@@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe } from 'lucide-react';
import { LayoutDashboard, Settings, Shield, X, Palette, Globe, CalendarDays } from 'lucide-react';
import BrandLogo from './BrandLogo';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
@@ -30,6 +30,7 @@ export default function Sidebar({ open, onClose }) {
const navItems = [
{ to: '/dashboard', icon: LayoutDashboard, label: t('nav.dashboard') },
{ to: '/calendar', icon: CalendarDays, label: t('nav.calendar') },
{ to: '/federation/inbox', icon: Globe, label: t('nav.federation'), badge: federationCount },
{ to: '/settings', icon: Settings, label: t('nav.settings') },
];

View File

@@ -32,6 +32,7 @@
"appearance": "Darstellung",
"changeTheme": "Theme ändern",
"navigation": "Navigation",
"calendar": "Kalender",
"federation": "Einladungen"
},
"auth": {
@@ -408,5 +409,58 @@
"joinUrl": "Beitritts-URL",
"roomDeleted": "Gelöscht",
"roomDeletedNotice": "Dieser Raum wurde vom Besitzer auf der Ursprungsinstanz gelöscht und ist nicht mehr verfügbar."
},
"calendar": {
"title": "Kalender",
"subtitle": "Meetings planen und verwalten",
"newEvent": "Neues Event",
"createEvent": "Event erstellen",
"editEvent": "Event bearbeiten",
"eventTitle": "Titel",
"eventTitlePlaceholder": "z.B. Team Meeting",
"description": "Beschreibung",
"descriptionPlaceholder": "Beschreibung hinzufügen...",
"startTime": "Beginn",
"endTime": "Ende",
"linkedRoom": "Verknüpfter Raum",
"noRoom": "Kein Raum (kein Videomeeting)",
"linkedRoomHint": "Verknüpfe einen Raum, um die Beitritts-URL automatisch ins Event einzufügen.",
"color": "Farbe",
"eventCreated": "Event erstellt!",
"eventUpdated": "Event aktualisiert!",
"eventDeleted": "Event gelöscht",
"saveFailed": "Event konnte nicht gespeichert werden",
"deleteFailed": "Event konnte nicht gelöscht werden",
"deleteConfirm": "Dieses Event wirklich löschen?",
"loadFailed": "Events konnten nicht geladen werden",
"today": "Heute",
"month": "Monat",
"week": "Woche",
"more": "weitere",
"mon": "Mo",
"tue": "Di",
"wed": "Mi",
"thu": "Do",
"fri": "Fr",
"sat": "Sa",
"sun": "So",
"downloadICS": "ICS herunterladen",
"icsDownloaded": "ICS-Datei heruntergeladen",
"icsFailed": "ICS-Datei konnte nicht heruntergeladen werden",
"share": "Teilen",
"shareEvent": "Event teilen",
"shareAdded": "Benutzer zum Event hinzugefügt",
"shareRemoved": "Freigabe entfernt",
"shareFailed": "Event konnte nicht geteilt werden",
"sendFederated": "An Remote senden",
"sendFederatedTitle": "Event an Remote-Instanz senden",
"sendFederatedDesc": "Sende dieses Kalender-Event an einen Benutzer auf einer anderen Redlight-Instanz.",
"send": "Senden",
"fedSent": "Event an Remote-Instanz gesendet!",
"fedFailed": "Event konnte nicht an Remote-Instanz gesendet werden",
"openRoom": "Verknüpften Raum öffnen",
"organizer": "Organisator",
"federatedFrom": "Von Remote-Instanz",
"joinFederatedMeeting": "Remote-Meeting beitreten"
}
}

View File

@@ -32,6 +32,7 @@
"appearance": "Appearance",
"changeTheme": "Change theme",
"navigation": "Navigation",
"calendar": "Calendar",
"federation": "Invitations"
},
"auth": {
@@ -408,5 +409,58 @@
"joinUrl": "Join URL",
"roomDeleted": "Deleted",
"roomDeletedNotice": "This room has been deleted by the owner on the origin instance and is no longer available."
},
"calendar": {
"title": "Calendar",
"subtitle": "Plan and manage your meetings",
"newEvent": "New Event",
"createEvent": "Create Event",
"editEvent": "Edit Event",
"eventTitle": "Title",
"eventTitlePlaceholder": "e.g. Team Meeting",
"description": "Description",
"descriptionPlaceholder": "Add a description...",
"startTime": "Start",
"endTime": "End",
"linkedRoom": "Linked Room",
"noRoom": "No room (no video meeting)",
"linkedRoomHint": "Link a room to automatically include the join-URL in the event.",
"color": "Color",
"eventCreated": "Event created!",
"eventUpdated": "Event updated!",
"eventDeleted": "Event deleted",
"saveFailed": "Could not save event",
"deleteFailed": "Could not delete event",
"deleteConfirm": "Really delete this event?",
"loadFailed": "Events could not be loaded",
"today": "Today",
"month": "Month",
"week": "Week",
"more": "more",
"mon": "Mon",
"tue": "Tue",
"wed": "Wed",
"thu": "Thu",
"fri": "Fri",
"sat": "Sat",
"sun": "Sun",
"downloadICS": "Download ICS",
"icsDownloaded": "ICS file downloaded",
"icsFailed": "Could not download ICS file",
"share": "Share",
"shareEvent": "Share Event",
"shareAdded": "User added to event",
"shareRemoved": "Share removed",
"shareFailed": "Could not share event",
"sendFederated": "Send to remote",
"sendFederatedTitle": "Send Event to Remote Instance",
"sendFederatedDesc": "Send this calendar event to a user on another Redlight instance.",
"send": "Send",
"fedSent": "Event sent to remote instance!",
"fedFailed": "Could not send event to remote instance",
"openRoom": "Open linked room",
"organizer": "Organizer",
"federatedFrom": "From remote instance",
"joinFederatedMeeting": "Join remote meeting"
}
}

View File

@@ -440,6 +440,32 @@
--gradient-end: #d6336a;
}
/* ===== RED MODULAR LIGHT ===== */
[data-theme="red-modular-light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f5f5;
--bg-tertiary: #e0e0e0;
--text-primary: #000000;
--text-secondary: #333333;
--accent: #e60000;
--accent-hover: #ff3333;
--accent-text: #ffffff;
--border: rgba(255, 255, 255, 0.1);
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #d3cbb7;
--nav-bg: #ffffff;
--sidebar-bg: #ffffff;
--hover-bg: #e0e0e0;
--success: #86b300;
--warning: #ecb637;
--error: #ec4137;
--ring: #b30051;
--shadow-color: rgba(0, 0, 0, 0.3);
--gradient-start: #b30051;
--gradient-end: #d6336a;
}
@layer components {
.btn-primary {

744
src/pages/Calendar.jsx Normal file
View File

@@ -0,0 +1,744 @@
import { useState, useEffect, useMemo } from 'react';
import {
ChevronLeft, ChevronRight, Plus, Calendar as CalendarIcon, Clock, Video,
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send,
} from 'lucide-react';
import api from '../services/api';
import { useAuth } from '../contexts/AuthContext';
import { useLanguage } from '../contexts/LanguageContext';
import Modal from '../components/Modal';
import toast from 'react-hot-toast';
const COLORS = ['#6366f1', '#ef4444', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6'];
export default function Calendar() {
const { user } = useAuth();
const { t } = useLanguage();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [currentDate, setCurrentDate] = useState(new Date());
const [view, setView] = useState('month'); // month | week
const [rooms, setRooms] = useState([]);
// Modal state
const [showCreate, setShowCreate] = useState(false);
const [showDetail, setShowDetail] = useState(null);
const [editingEvent, setEditingEvent] = useState(null);
const [showShare, setShowShare] = useState(null);
const [showFedShare, setShowFedShare] = useState(null);
// Create/Edit form
const [form, setForm] = useState({
title: '', description: '', start_time: '', end_time: '',
room_uid: '', color: '#6366f1',
});
const [saving, setSaving] = useState(false);
// Share state
const [shareSearch, setShareSearch] = useState('');
const [shareResults, setShareResults] = useState([]);
const [sharedUsers, setSharedUsers] = useState([]);
const [fedAddress, setFedAddress] = useState('');
const [fedSending, setFedSending] = useState(false);
// Load events on month change
useEffect(() => {
fetchEvents();
}, [currentDate]);
useEffect(() => {
fetchRooms();
}, []);
const fetchEvents = async () => {
try {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const from = new Date(year, month - 1, 1).toISOString();
const to = new Date(year, month + 2, 0).toISOString();
const res = await api.get(`/calendar/events?from=${from}&to=${to}`);
setEvents(res.data.events || []);
} catch {
toast.error(t('calendar.loadFailed'));
} finally {
setLoading(false);
}
};
const fetchRooms = async () => {
try {
const res = await api.get('/rooms');
setRooms(res.data.rooms || []);
} catch { /* ignore */ }
};
// Calendar grid computation
const calendarDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Start from Monday (ISO week)
let startOffset = firstDay.getDay() - 1;
if (startOffset < 0) startOffset = 6;
const calStart = new Date(year, month, 1 - startOffset);
const days = [];
const current = new Date(calStart);
for (let i = 0; i < 42; i++) {
days.push(new Date(current));
current.setDate(current.getDate() + 1);
}
return days;
}, [currentDate]);
const weekDays = useMemo(() => {
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
const date = currentDate.getDate();
const dayOfWeek = currentDate.getDay();
let mondayOffset = dayOfWeek - 1;
if (mondayOffset < 0) mondayOffset = 6;
const monday = new Date(year, month, date - mondayOffset);
const days = [];
for (let i = 0; i < 7; i++) {
days.push(new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + i));
}
return days;
}, [currentDate]);
const eventsForDay = (day) => {
const dayStr = day.toISOString().split('T')[0];
return events.filter(ev => {
const start = ev.start_time.split('T')[0];
const end = ev.end_time.split('T')[0];
return dayStr >= start && dayStr <= end;
});
};
const isToday = (day) => {
const today = new Date();
return day.toDateString() === today.toDateString();
};
const isCurrentMonth = (day) => {
return day.getMonth() === currentDate.getMonth();
};
const navigatePrev = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() - 1);
else d.setDate(d.getDate() - 7);
setCurrentDate(d);
};
const navigateNext = () => {
const d = new Date(currentDate);
if (view === 'month') d.setMonth(d.getMonth() + 1);
else d.setDate(d.getDate() + 7);
setCurrentDate(d);
};
const goToToday = () => setCurrentDate(new Date());
const monthLabel = currentDate.toLocaleString('default', { month: 'long', year: 'numeric' });
const openCreateForDay = (day) => {
const start = new Date(day);
start.setHours(9, 0, 0, 0);
const end = new Date(day);
end.setHours(10, 0, 0, 0);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(start),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
};
const openEdit = (ev) => {
setForm({
title: ev.title,
description: ev.description || '',
start_time: toLocalDateTimeStr(new Date(ev.start_time)),
end_time: toLocalDateTimeStr(new Date(ev.end_time)),
room_uid: ev.room_uid || '',
color: ev.color || '#6366f1',
});
setEditingEvent(ev);
setShowDetail(null);
setShowCreate(true);
};
const handleSave = async (e) => {
e.preventDefault();
setSaving(true);
try {
const data = {
...form,
start_time: new Date(form.start_time).toISOString(),
end_time: new Date(form.end_time).toISOString(),
};
if (editingEvent) {
await api.put(`/calendar/events/${editingEvent.id}`, data);
toast.success(t('calendar.eventUpdated'));
} else {
await api.post('/calendar/events', data);
toast.success(t('calendar.eventCreated'));
}
setShowCreate(false);
setEditingEvent(null);
fetchEvents();
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.saveFailed'));
} finally {
setSaving(false);
}
};
const handleDelete = async (ev) => {
if (!confirm(t('calendar.deleteConfirm'))) return;
try {
await api.delete(`/calendar/events/${ev.id}`);
toast.success(t('calendar.eventDeleted'));
setShowDetail(null);
fetchEvents();
} catch {
toast.error(t('calendar.deleteFailed'));
}
};
const handleDownloadICS = async (ev) => {
try {
const res = await api.get(`/calendar/events/${ev.id}/ics`, { responseType: 'blob' });
const url = window.URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = `${ev.title}.ics`;
a.click();
window.URL.revokeObjectURL(url);
toast.success(t('calendar.icsDownloaded'));
} catch {
toast.error(t('calendar.icsFailed'));
}
};
// Share functions
const openShareModal = async (ev) => {
setShowShare(ev);
setShareSearch('');
setShareResults([]);
try {
const res = await api.get(`/calendar/events/${ev.id}`);
setSharedUsers(res.data.sharedUsers || []);
} catch { /* ignore */ }
};
const searchUsers = async (query) => {
setShareSearch(query);
if (query.length < 2) { setShareResults([]); return; }
try {
const res = await api.get(`/rooms/users/search?q=${encodeURIComponent(query)}`);
const sharedIds = new Set(sharedUsers.map(u => u.id));
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id)));
} catch { setShareResults([]); }
};
const handleShare = async (userId) => {
if (!showShare) return;
try {
const res = await api.post(`/calendar/events/${showShare.id}/share`, { user_id: userId });
setSharedUsers(res.data.sharedUsers);
setShareSearch('');
setShareResults([]);
toast.success(t('calendar.shareAdded'));
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.shareFailed'));
}
};
const handleUnshare = async (userId) => {
if (!showShare) return;
try {
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
setSharedUsers(res.data.sharedUsers);
toast.success(t('calendar.shareRemoved'));
} catch { toast.error(t('calendar.shareFailed')); }
};
const handleFedSend = async (e) => {
e.preventDefault();
if (!showFedShare) return;
const normalized = fedAddress.startsWith('@') ? fedAddress.slice(1) : fedAddress;
if (!normalized.includes('@') || normalized.endsWith('@')) {
toast.error(t('federation.addressHint'));
return;
}
setFedSending(true);
try {
await api.post(`/calendar/events/${showFedShare.id}/federation`, { to: fedAddress });
toast.success(t('calendar.fedSent'));
setShowFedShare(null);
setFedAddress('');
} catch (err) {
toast.error(err.response?.data?.error || t('calendar.fedFailed'));
} finally {
setFedSending(false);
}
};
const dayNames = [
t('calendar.mon'), t('calendar.tue'), t('calendar.wed'),
t('calendar.thu'), t('calendar.fri'), t('calendar.sat'), t('calendar.sun'),
];
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 size={32} className="animate-spin text-th-accent" />
</div>
);
}
return (
<div>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-th-text">{t('calendar.title')}</h1>
<p className="text-sm text-th-text-s mt-1">{t('calendar.subtitle')}</p>
</div>
<button onClick={() => {
const now = new Date();
now.setHours(now.getHours() + 1, 0, 0, 0);
const end = new Date(now);
end.setHours(end.getHours() + 1);
setForm({
title: '', description: '',
start_time: toLocalDateTimeStr(now),
end_time: toLocalDateTimeStr(end),
room_uid: '', color: '#6366f1',
});
setEditingEvent(null);
setShowCreate(true);
}} className="btn-primary">
<Plus size={18} />
<span className="hidden sm:inline">{t('calendar.newEvent')}</span>
</button>
</div>
{/* Toolbar */}
<div className="card p-3 mb-4 flex items-center justify-between flex-wrap gap-2">
<div className="flex items-center gap-2">
<button onClick={navigatePrev} className="btn-ghost p-2">
<ChevronLeft size={18} />
</button>
<button onClick={goToToday} className="btn-ghost text-sm px-3 py-1.5">
{t('calendar.today')}
</button>
<button onClick={navigateNext} className="btn-ghost p-2">
<ChevronRight size={18} />
</button>
<h2 className="text-lg font-semibold text-th-text ml-2">{monthLabel}</h2>
</div>
<div className="flex items-center bg-th-bg-s rounded-lg border border-th-border p-0.5">
<button
onClick={() => setView('month')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'month' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.month')}
</button>
<button
onClick={() => setView('week')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
view === 'week' ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s hover:text-th-text'
}`}
>
{t('calendar.week')}
</button>
</div>
</div>
{/* Calendar Grid */}
<div className="card overflow-hidden">
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-th-border">
{dayNames.map((name, i) => (
<div key={i} className="py-2.5 text-center text-xs font-semibold text-th-text-s uppercase tracking-wider border-r border-th-border last:border-r-0">
{name}
</div>
))}
</div>
{/* Days */}
{view === 'month' ? (
<div className="grid grid-cols-7">
{calendarDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
const inMonth = isCurrentMonth(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className={`min-h-[100px] p-1.5 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors
${!inMonth ? 'opacity-40' : ''}`}
>
<div className={`text-xs font-medium mb-1 w-6 h-6 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-0.5">
{dayEvents.slice(0, 3).map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-[10px] leading-tight px-1.5 py-0.5 rounded truncate text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
title={ev.title}
>
{formatTime(ev.start_time)} {ev.title}
</div>
))}
{dayEvents.length > 3 && (
<div className="text-[10px] text-th-text-s font-medium px-1.5">
+{dayEvents.length - 3} {t('calendar.more')}
</div>
)}
</div>
</div>
);
})}
</div>
) : (
/* Week view */
<div className="grid grid-cols-7">
{weekDays.map((day, i) => {
const dayEvents = eventsForDay(day);
const today = isToday(day);
return (
<div
key={i}
onClick={() => openCreateForDay(day)}
className="min-h-[300px] p-2 border-r border-b border-th-border last:border-r-0 cursor-pointer hover:bg-th-hover/50 transition-colors"
>
<div className={`text-sm font-medium mb-2 w-7 h-7 flex items-center justify-center rounded-full
${today ? 'bg-th-accent text-th-accent-t' : 'text-th-text-s'}`}>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEvents.map(ev => (
<div
key={ev.id}
onClick={(e) => { e.stopPropagation(); setShowDetail(ev); }}
className="text-xs px-2 py-1.5 rounded text-white font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: ev.color || '#6366f1' }}
>
<div className="truncate">{ev.title}</div>
<div className="opacity-80 text-[10px]">{formatTime(ev.start_time)} {formatTime(ev.end_time)}</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</div>
{/* Create/Edit Modal */}
{showCreate && (
<Modal title={editingEvent ? t('calendar.editEvent') : t('calendar.createEvent')} onClose={() => { setShowCreate(false); setEditingEvent(null); }}>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.eventTitle')} *</label>
<input
type="text"
value={form.title}
onChange={e => setForm({ ...form, title: e.target.value })}
className="input-field"
placeholder={t('calendar.eventTitlePlaceholder')}
required
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.description')}</label>
<textarea
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
className="input-field resize-none"
rows={2}
placeholder={t('calendar.descriptionPlaceholder')}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.startTime')} *</label>
<input
type="datetime-local"
value={form.start_time}
onChange={e => setForm({ ...form, start_time: e.target.value })}
className="input-field"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.endTime')} *</label>
<input
type="datetime-local"
value={form.end_time}
onChange={e => setForm({ ...form, end_time: e.target.value })}
className="input-field"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.linkedRoom')}</label>
<select
value={form.room_uid}
onChange={e => setForm({ ...form, room_uid: e.target.value })}
className="input-field"
>
<option value="">{t('calendar.noRoom')}</option>
{rooms.map(r => (
<option key={r.uid} value={r.uid}>{r.name}</option>
))}
</select>
<p className="text-xs text-th-text-s mt-1">{t('calendar.linkedRoomHint')}</p>
</div>
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('calendar.color')}</label>
<div className="flex gap-2">
{COLORS.map(c => (
<button
key={c}
type="button"
onClick={() => setForm({ ...form, color: c })}
className={`w-7 h-7 rounded-full border-2 transition-all ${form.color === c ? 'border-th-text scale-110' : 'border-transparent'}`}
style={{ backgroundColor: c }}
/>
))}
</div>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-th-border">
<button type="button" onClick={() => { setShowCreate(false); setEditingEvent(null); }} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={saving} className="btn-primary flex-1">
{saving ? <Loader2 size={18} className="animate-spin" /> : t('common.save')}
</button>
</div>
</form>
</Modal>
)}
{/* Event Detail Modal */}
{showDetail && (
<Modal title={showDetail.title} onClose={() => setShowDetail(null)}>
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-th-text-s">
<Clock size={14} />
<span>
{new Date(showDetail.start_time).toLocaleString()} {new Date(showDetail.end_time).toLocaleString()}
</span>
</div>
{showDetail.description && (
<p className="text-sm text-th-text">{showDetail.description}</p>
)}
{showDetail.room_uid && (
<div className="flex items-center gap-2 text-sm">
<Video size={14} className="text-th-accent" />
<a
href={`/rooms/${showDetail.room_uid}`}
className="text-th-accent hover:underline"
onClick={(e) => { e.preventDefault(); window.location.href = `/rooms/${showDetail.room_uid}`; }}
>
{t('calendar.openRoom')}
</a>
</div>
)}
{showDetail.federated_from && (
<div className="flex items-center gap-2 text-xs text-th-text-s">
<Globe size={12} />
<span>{t('calendar.federatedFrom')}: {showDetail.federated_from}</span>
</div>
)}
{showDetail.federated_join_url && (
<a
href={showDetail.federated_join_url}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm w-full justify-center"
>
<Video size={14} />
{t('calendar.joinFederatedMeeting')}
</a>
)}
{showDetail.organizer_name && (
<div className="text-xs text-th-text-s">
{t('calendar.organizer')}: {showDetail.organizer_name}
</div>
)}
{/* Actions */}
<div className="flex flex-wrap items-center gap-2 pt-4 border-t border-th-border">
<button onClick={() => handleDownloadICS(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Download size={14} />
{t('calendar.downloadICS')}
</button>
{showDetail.is_owner && (
<>
<button onClick={() => openEdit(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Edit size={14} />
{t('common.edit')}
</button>
<button onClick={() => openShareModal(showDetail)} className="btn-ghost text-xs py-1.5 px-3">
<Share2 size={14} />
{t('calendar.share')}
</button>
<button onClick={() => { setShowFedShare(showDetail); setShowDetail(null); }} className="btn-ghost text-xs py-1.5 px-3">
<Globe size={14} />
{t('calendar.sendFederated')}
</button>
<button onClick={() => handleDelete(showDetail)} className="btn-ghost text-xs py-1.5 px-3 text-th-error hover:text-th-error">
<Trash2 size={14} />
{t('common.delete')}
</button>
</>
)}
</div>
</div>
</Modal>
)}
{/* Share Modal */}
{showShare && (
<Modal title={t('calendar.shareEvent')} onClose={() => setShowShare(null)}>
<div className="space-y-4">
<div className="relative">
<UserPlus size={18} className="absolute left-3.5 top-1/2 -translate-y-1/2 text-th-text-s" />
<input
type="text"
value={shareSearch}
onChange={e => searchUsers(e.target.value)}
className="input-field pl-11"
placeholder={t('room.shareSearchPlaceholder')}
/>
{shareResults.length > 0 && (
<div className="absolute z-10 w-full mt-1 bg-th-card border border-th-border rounded-lg shadow-lg max-h-48 overflow-y-auto">
{shareResults.map(u => (
<button
key={u.id}
onClick={() => handleShare(u.id)}
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-th-hover transition-colors text-left"
>
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</button>
))}
</div>
)}
</div>
{sharedUsers.length > 0 && (
<div className="space-y-2">
{sharedUsers.map(u => (
<div key={u.id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border">
<div className="flex items-center gap-3 min-w-0">
<div
className="w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-bold flex-shrink-0"
style={{ backgroundColor: u.avatar_color || '#6366f1' }}
>
{(u.display_name || u.name).split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)}
</div>
<div className="min-w-0">
<div className="text-sm font-medium text-th-text truncate">{u.display_name || u.name}</div>
<div className="text-xs text-th-text-s truncate">{u.email}</div>
</div>
</div>
<button onClick={() => handleUnshare(u.id)} className="p-1.5 rounded-lg hover:bg-th-hover text-th-text-s hover:text-th-error transition-colors">
<X size={16} />
</button>
</div>
))}
</div>
)}
</div>
</Modal>
)}
{/* Federation Share Modal */}
{showFedShare && (
<Modal title={t('calendar.sendFederatedTitle')} onClose={() => setShowFedShare(null)}>
<p className="text-sm text-th-text-s mb-4">{t('calendar.sendFederatedDesc')}</p>
<form onSubmit={handleFedSend} className="space-y-4">
<div>
<label className="block text-sm font-medium text-th-text mb-1.5">{t('federation.addressLabel')}</label>
<input
type="text"
value={fedAddress}
onChange={e => setFedAddress(e.target.value)}
className="input-field"
placeholder={t('federation.addressPlaceholder')}
required
/>
<p className="text-xs text-th-text-s mt-1">{t('federation.addressHint')}</p>
</div>
<div className="flex items-center gap-3 pt-2 border-t border-th-border">
<button type="button" onClick={() => setShowFedShare(null)} className="btn-secondary flex-1">
{t('common.cancel')}
</button>
<button type="submit" disabled={fedSending} className="btn-primary flex-1">
{fedSending ? <Loader2 size={16} className="animate-spin" /> : <Send size={16} />}
{t('calendar.send')}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
// Helpers
function toLocalDateTimeStr(date) {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d}T${h}:${min}`;
}
function formatTime(dateStr) {
const d = new Date(dateStr);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}

View File

@@ -111,6 +111,13 @@ export const themes = [
group: 'Community',
colors: { bg: '#161924', accent: '#b30051', text: '#dadada' },
},
{
id: 'red-modular-light',
name: 'Red Modular Light',
type: 'light',
group: 'Community',
colors: { bg: '#ffffff', accent: '#e60000', text: '#000000' },
},
];
export function getThemeById(id) {