889 lines
35 KiB
JavaScript
889 lines
35 KiB
JavaScript
import { useState, useEffect, useMemo } from 'react';
|
|
import {
|
|
ChevronLeft, ChevronRight, Plus, Clock, Video, Bell,
|
|
Loader2, Download, Share2, Globe, Trash2, Edit, X, UserPlus, Send, ExternalLink,
|
|
} from 'lucide-react';
|
|
import api from '../services/api';
|
|
import { useAuth } from '../contexts/AuthContext';
|
|
import { useLanguage } from '../contexts/LanguageContext';
|
|
import Modal from '../components/Modal';
|
|
import DateTimePicker from '../components/DateTimePicker';
|
|
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', reminder_minutes: null,
|
|
});
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// Share state
|
|
const [shareSearch, setShareSearch] = useState('');
|
|
const [shareResults, setShareResults] = useState([]);
|
|
const [sharedUsers, setSharedUsers] = useState([]);
|
|
const [pendingInvitations, setPendingInvitations] = 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 = toLocalDateStr(day);
|
|
return events.filter(ev => {
|
|
const start = toLocalDateStr(new Date(ev.start_time));
|
|
const end = toLocalDateStr(new Date(ev.end_time));
|
|
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', reminder_minutes: null,
|
|
});
|
|
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',
|
|
reminder_minutes: ev.reminder_minutes ?? null,
|
|
});
|
|
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'));
|
|
}
|
|
};
|
|
|
|
const buildOutlookUrl = (ev) => {
|
|
const start = new Date(ev.start_time);
|
|
const end = new Date(ev.end_time);
|
|
const fmt = (d) => d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
const baseUrl = window.location.origin;
|
|
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
|
|
const body = [ev.description || '', location ? `\n\nMeeting: ${location}` : ''].join('');
|
|
const params = new URLSearchParams({
|
|
rru: 'addevent',
|
|
subject: ev.title,
|
|
startdt: start.toISOString(),
|
|
enddt: end.toISOString(),
|
|
body: body.trim(),
|
|
location,
|
|
allday: 'false',
|
|
path: '/calendar/action/compose',
|
|
});
|
|
return `https://outlook.live.com/calendar/0/action/compose?${params.toString()}`;
|
|
};
|
|
|
|
const buildGoogleCalUrl = (ev) => {
|
|
const fmt = (d) => new Date(d).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
|
|
const baseUrl = window.location.origin;
|
|
const location = ev.room_uid ? `${baseUrl}/join/${ev.room_uid}` : '';
|
|
const details = [ev.description || '', location ? `\nMeeting: ${location}` : ''].join('');
|
|
const params = new URLSearchParams({
|
|
action: 'TEMPLATE',
|
|
text: ev.title,
|
|
dates: `${fmt(ev.start_time)}/${fmt(ev.end_time)}`,
|
|
details: details.trim(),
|
|
location,
|
|
});
|
|
return `https://calendar.google.com/calendar/render?${params.toString()}`;
|
|
};
|
|
|
|
// Share functions
|
|
const openShareModal = async (ev) => {
|
|
setShowShare(ev);
|
|
setShareSearch('');
|
|
setShareResults([]);
|
|
setPendingInvitations([]);
|
|
try {
|
|
const res = await api.get(`/calendar/events/${ev.id}`);
|
|
setSharedUsers(res.data.sharedUsers || []);
|
|
setPendingInvitations(res.data.pendingInvitations || []);
|
|
} 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));
|
|
const pendingIds = new Set(pendingInvitations.map(u => u.user_id));
|
|
setShareResults(res.data.users.filter(u => !sharedIds.has(u.id) && !pendingIds.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);
|
|
setPendingInvitations(res.data.pendingInvitations || []);
|
|
setShareSearch('');
|
|
setShareResults([]);
|
|
toast.success(t('calendar.invitationSent'));
|
|
} 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);
|
|
setPendingInvitations(res.data.pendingInvitations || []);
|
|
toast.success(t('calendar.shareRemoved'));
|
|
} catch { toast.error(t('calendar.shareFailed')); }
|
|
};
|
|
|
|
const handleCancelInvitation = async (userId) => {
|
|
if (!showShare) return;
|
|
try {
|
|
const res = await api.delete(`/calendar/events/${showShare.id}/share/${userId}`);
|
|
setSharedUsers(res.data.sharedUsers);
|
|
setPendingInvitations(res.data.pendingInvitations || []);
|
|
toast.success(t('calendar.invitationCancelled'));
|
|
} 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="flex items-center gap-1 truncate">
|
|
{ev.reminder_minutes && <Bell size={9} className="flex-shrink-0 opacity-70" />}
|
|
<span className="truncate">{ev.title}</span>
|
|
</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">
|
|
<DateTimePicker
|
|
label={t('calendar.startTime')}
|
|
value={form.start_time}
|
|
onChange={v => setForm({ ...form, start_time: v })}
|
|
required
|
|
icon="calendar"
|
|
/>
|
|
<DateTimePicker
|
|
label={t('calendar.endTime')}
|
|
value={form.end_time}
|
|
onChange={v => setForm({ ...form, end_time: v })}
|
|
required
|
|
icon="clock"
|
|
minDate={form.start_time ? new Date(form.start_time) : null}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 -mt-2 text-xs text-th-text-s">
|
|
<Globe size={12} className="flex-shrink-0" />
|
|
<span>{getLocalTimezone()}</span>
|
|
</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.reminderLabel')}</label>
|
|
<div className="relative">
|
|
<Bell size={15} className="absolute left-3 top-1/2 -translate-y-1/2 text-th-text-s pointer-events-none" />
|
|
<select
|
|
value={form.reminder_minutes ?? ''}
|
|
onChange={e => setForm({ ...form, reminder_minutes: e.target.value === '' ? null : Number(e.target.value) })}
|
|
className="input-field pl-9"
|
|
>
|
|
<option value="">{t('calendar.reminderNone')}</option>
|
|
<option value="5">{t('calendar.reminder5')}</option>
|
|
<option value="15">{t('calendar.reminder15')}</option>
|
|
<option value="30">{t('calendar.reminder30')}</option>
|
|
<option value="60">{t('calendar.reminder60')}</option>
|
|
<option value="120">{t('calendar.reminder120')}</option>
|
|
<option value="1440">{t('calendar.reminder1440')}</option>
|
|
</select>
|
|
</div>
|
|
</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>
|
|
<div className="flex items-center gap-1.5 text-xs text-th-text-s opacity-70 -mt-2">
|
|
<Globe size={12} />
|
|
<span>{getLocalTimezone()}</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">
|
|
<a
|
|
href={buildOutlookUrl(showDetail)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
|
|
>
|
|
<ExternalLink size={14} />
|
|
{t('calendar.addToOutlook')}
|
|
</a>
|
|
<a
|
|
href={buildGoogleCalUrl(showDetail)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="btn-ghost text-xs py-1.5 px-3 inline-flex items-center gap-1.5 no-underline"
|
|
>
|
|
<ExternalLink size={14} />
|
|
{t('calendar.addToGoogleCalendar')}
|
|
</a>
|
|
<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>
|
|
|
|
{pendingInvitations.length > 0 && (
|
|
<div className="space-y-2">
|
|
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.pendingInvitations')}</p>
|
|
{pendingInvitations.map(u => (
|
|
<div key={u.user_id} className="flex items-center justify-between gap-3 p-3 bg-th-bg-s rounded-lg border border-th-border border-dashed">
|
|
<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-warning">{t('calendar.invitationPending')}</div>
|
|
</div>
|
|
</div>
|
|
<button onClick={() => handleCancelInvitation(u.user_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>
|
|
)}
|
|
|
|
{sharedUsers.length > 0 && (
|
|
<div className="space-y-2">
|
|
{pendingInvitations.length > 0 && (
|
|
<p className="text-xs font-semibold text-th-text-s uppercase tracking-wider">{t('calendar.accepted')}</p>
|
|
)}
|
|
{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 toLocalDateStr(date) {
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
|
|
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' });
|
|
}
|
|
|
|
function getLocalTimezone() {
|
|
try {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
} catch {
|
|
const offset = -new Date().getTimezoneOffset();
|
|
const sign = offset >= 0 ? '+' : '-';
|
|
const h = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
|
|
const m = String(Math.abs(offset) % 60).padStart(2, '0');
|
|
return `UTC${sign}${h}:${m}`;
|
|
}
|
|
}
|