v0.9.63 updated for mobile
This commit is contained in:
@@ -4,6 +4,8 @@ import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import UserFooter from './UserFooter.jsx';
|
||||
import MobileEventForm from './MobileEventForm.jsx';
|
||||
import MobileGroupManager from './MobileGroupManager.jsx';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
@@ -164,6 +166,89 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recurrence helpers ────────────────────────────────────────────────────────
|
||||
const FREQ_OPTIONS = [
|
||||
{ value: '', label: 'Does not repeat' },
|
||||
{ value: 'daily', label: 'Every day' },
|
||||
{ value: 'weekly', label: 'Every week' },
|
||||
{ value: 'monthly', label: 'Every month' },
|
||||
{ value: 'yearly', label: 'Every year' },
|
||||
{ value: 'custom', label: 'Custom…' },
|
||||
];
|
||||
const DAY_PILLS = ['S','M','T','W','T','F','S'];
|
||||
const DAY_KEYS = ['SU','MO','TU','WE','TH','FR','SA'];
|
||||
|
||||
function recurrenceLabel(rule) {
|
||||
if (!rule || !rule.freq) return 'Does not repeat';
|
||||
const opt = FREQ_OPTIONS.find(o => o.value === rule.freq);
|
||||
if (rule.freq !== 'custom') return opt?.label || rule.freq;
|
||||
// Custom summary
|
||||
const unit = rule.interval === 1 ? rule.unit : `${rule.interval} ${rule.unit}s`;
|
||||
return `Every ${unit}`;
|
||||
}
|
||||
|
||||
// Desktop recurrence selector — shown inline in the form
|
||||
function RecurrenceSelector({ value, onChange }) {
|
||||
// value: { freq, interval, unit, byDay, ends, endDate, endCount } or null
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const rule = value || {};
|
||||
|
||||
const handleFreqChange = (freq) => {
|
||||
if (freq === '') { onChange(null); return; }
|
||||
if (freq === 'custom') { setShowCustom(true); onChange({ freq:'custom', interval:1, unit:'week', byDay:[], ends:'never', endDate:'', endCount:13 }); return; }
|
||||
setShowCustom(false);
|
||||
onChange({ freq });
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<select className="input" value={rule.freq||''} onChange={e=>handleFreqChange(e.target.value)} style={{marginBottom: (rule.freq==='custom'||showCustom) ? 12 : 0}}>
|
||||
{FREQ_OPTIONS.map(o=><option key={o.value} value={o.value}>{o.label}</option>)}
|
||||
</select>
|
||||
{(rule.freq==='custom') && (
|
||||
<CustomRecurrenceFields rule={rule} onChange={onChange}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomRecurrenceFields({ rule, onChange }) {
|
||||
const upd = (k,v) => onChange({...rule,[k]:v});
|
||||
return (
|
||||
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
||||
<span style={{color:'var(--text-tertiary)'}}>Every</span>
|
||||
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e=>upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/>
|
||||
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
|
||||
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{(rule.unit||'week')==='week' && (
|
||||
<div>
|
||||
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Repeats on</div>
|
||||
<div style={{display:'flex',gap:6}}>
|
||||
{DAY_PILLS.map((d,i)=>{
|
||||
const key=DAY_KEYS[i], sel=(rule.byDay||[]).includes(key);
|
||||
return <button key={key} type="button" onClick={()=>upd('byDay',sel?(rule.byDay||[]).filter(x=>x!==key):[...(rule.byDay||[]),key])} style={{width:32,height:32,borderRadius:'50%',border:'1px solid var(--border)',background:sel?'var(--primary)':'transparent',color:sel?'white':'var(--text-primary)',fontSize:11,fontWeight:600,cursor:'pointer'}}>{d}</button>;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div style={{fontSize:12,color:'var(--text-tertiary)',marginBottom:6}}>Ends</div>
|
||||
{[['never','Never'],['on','On date'],['after','After']].map(([val,lbl])=>(
|
||||
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
|
||||
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
|
||||
{lbl}
|
||||
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e=>upd('endDate',e.target.value)} style={{marginLeft:8,flex:1}}/>}
|
||||
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e=>upd('endCount',parseInt(e.target.value)||1)} style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Shared Row layout — defined OUTSIDE EventForm so it's stable across renders ─
|
||||
function FormRow({ label, children, required }) {
|
||||
return (
|
||||
@@ -195,6 +280,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
const [saving,setSaving]=useState(false);
|
||||
const [showTypeForm,setShowTypeForm]=useState(false);
|
||||
const [localTypes,setLocalTypes]=useState(eventTypes);
|
||||
const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
|
||||
// Sync localTypes when parent provides updated eventTypes (e.g. after async load)
|
||||
// Also initialise typeId to the default event type for new events
|
||||
useEffect(()=>{
|
||||
@@ -262,7 +348,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
|
||||
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error');
|
||||
setSaving(true);
|
||||
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps]};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
try{const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?`${sd}T00:00:00`:buildISO(sd,st),endAt:allDay?`${ed}T23:59:59`:buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};const r=event?await api.updateEvent(event.id,body):await api.createEvent(body);onSave(r.event);}catch(e){toast(e.message,'error');}finally{setSaving(false);}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -308,10 +394,11 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer'}}>
|
||||
<input type="checkbox" checked={allDay} onChange={e=>setAllDay(e.target.checked)}/> All day
|
||||
</label>
|
||||
<label style={{display:'flex',alignItems:'center',gap:8,fontSize:13,cursor:'pointer',color:'var(--text-tertiary)'}}>
|
||||
<input type="checkbox" disabled title="Recurring events coming soon"/> Recurring
|
||||
<span style={{fontSize:11,background:'var(--surface-variant)',borderRadius:10,padding:'1px 6px'}}>Coming soon</span>
|
||||
</label>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span style={{color:'var(--text-tertiary)',flexShrink:0}}>Repeat:</span>
|
||||
<div style={{flex:1}}><RecurrenceSelector value={recRule} onChange={setRecRule}/></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormRow>
|
||||
@@ -421,6 +508,12 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
|
||||
<span>{fmtDate(new Date(event.start_at))}{!event.all_day&&` · ${fmtRange(event.start_at,event.end_at)}`}</span>
|
||||
</div>
|
||||
{event.recurrence_rule?.freq&&(
|
||||
<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
<span>{recurrenceLabel(event.recurrence_rule)}</span>
|
||||
</div>
|
||||
)}
|
||||
{event.location&&<div style={{display:'flex',gap:10,alignItems:'center',marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>{event.location}</div>}
|
||||
{event.description&&<div style={{display:'flex',gap:10,marginBottom:12,fontSize:14}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg><span style={{whiteSpace:'pre-wrap'}}>{event.description}</span></div>}
|
||||
{(event.user_groups||[]).length>0&&<div style={{display:'flex',gap:10,marginBottom:16,fontSize:13,color:'var(--text-secondary)'}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{flexShrink:0,marginTop:2}}><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/></svg>{event.user_groups.map(g=>g.name).join(', ')}</div>}
|
||||
@@ -751,6 +844,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
const [detailEvent, setDetailEvent] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
|
||||
const createRef = useRef(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -918,13 +1012,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
|
||||
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('schedule');}}/>}
|
||||
|
||||
{panel === 'eventForm' && isToolManager && (
|
||||
{panel === 'eventForm' && isToolManager && !isMobile && (
|
||||
<div style={{ padding:28, maxWidth:1024 }}>
|
||||
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
|
||||
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
|
||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
|
||||
</div>
|
||||
)}
|
||||
{panel === 'eventForm' && isToolManager && isMobile && (
|
||||
<MobileEventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager}
|
||||
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);}} onDelete={handleDelete}/>
|
||||
)}
|
||||
{panel === 'eventTypes' && isToolManager && (
|
||||
<div style={{ padding:28 }}>
|
||||
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:24 }}>
|
||||
@@ -946,6 +1044,22 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Group Manager */}
|
||||
{isMobile && mobilePanel === 'groupManager' && (
|
||||
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
|
||||
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile FAB for creating events */}
|
||||
{isMobile && isToolManager && panel === 'calendar' && (
|
||||
<button onClick={()=>{setPanel('eventForm');setEditingEvent(null);}} style={{ position:'fixed',bottom:24,right:24,zIndex:30,width:56,height:56,borderRadius:'50%',background:'var(--primary)',color:'white',border:'none',cursor:'pointer',boxShadow:'0 4px 16px rgba(0,0,0,0.25)',display:'flex',alignItems:'center',justifyContent:'center' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Event detail modal */}
|
||||
{detailEvent && (
|
||||
<EventDetailModal
|
||||
|
||||
Reference in New Issue
Block a user