v0.12.31 multiple UI changes

This commit is contained in:
2026-03-27 10:19:52 -04:00
parent d6a37d5948
commit 97f1dace4f
10 changed files with 174 additions and 69 deletions

View File

@@ -39,6 +39,18 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
return () => window.removeEventListener('resize', onResize);
}, []);
// On mobile, when the soft keyboard opens the visual viewport shrinks but the
// messages-container scroll position stays where it was, leaving the latest
// messages hidden behind the keyboard. Scroll to bottom whenever the visual
// viewport resizes (keyboard appear/dismiss) so the last message stays visible.
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return;
const onVVResize = () => scrollToBottom();
vv.addEventListener('resize', onVVResize);
return () => vv.removeEventListener('resize', onVVResize);
}, [scrollToBottom]);
useEffect(() => {
api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || '');
@@ -339,7 +351,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
This channel is read-only
</div>
) : (
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} />
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
)}
</div>
{showInfo && (

View File

@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
return emojiRegex.test(str.trim());
}
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onlineUserIds = new Set() }) {
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onInputFocus, onlineUserIds = new Set() }) {
const [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null);
@@ -380,10 +380,11 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
<textarea
ref={inputRef}
className="msg-input"
placeholder={`Message ${group?.name || ''}...`}
placeholder="Text message"
value={text}
onChange={handleChange}
onKeyDown={handleKeyDown}
onFocus={onInputFocus}
rows={1}
style={{ resize: 'none' }} />
</div>

View File

@@ -346,7 +346,7 @@ function RecurringChoiceModal({ title, onConfirm, onCancel }) {
}
// ── Main Mobile Event Form ────────────────────────────────────────────────────
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
const toast = useToast();
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
const defDate = selectedDate || new Date();
@@ -373,7 +373,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const mountedRef = useRef(false);
const [allDay, setAllDay] = useState(!!event?.all_day);
const [track, setTrack] = useState(!!event?.track_availability);
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : !isToolManager);
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [location, setLocation] = useState(event?.location||'');
const [description, setDescription] = useState(event?.description||'');
@@ -439,6 +439,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const handle = () => {
if(!title.trim()) return toast('Title required','error');
if(!isToolManager && groups.size === 0) return toast('Select at least one group','error');
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
if(ed < sd) return toast('End date cannot be before start date','error');
@@ -452,7 +453,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setShowScopeModal(false);
setSaving(true);
try {
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
onSave(r.event);
} catch(e) { toast(e.message,'error'); }
@@ -554,11 +555,14 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
))}
</div>
{/* Private Event */}
{/* Private Event — tool managers can toggle; regular users always private */}
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
<span style={{ flex:1,fontSize:15 }}>Private Event</span>
<Toggle checked={isPrivate} onChange={setIsPrivate}/>
{isToolManager
? <Toggle checked={isPrivate} onChange={setIsPrivate}/>
: <span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Always private</span>
}
</div>
{/* Location */}
@@ -572,7 +576,7 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
</MobileRow>
{/* Delete */}
{event && isToolManager && (
{event && (isToolManager || (userId && event.created_by === userId)) && (
<div style={{ padding:'16px 20px' }}>
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
</div>

View File

@@ -538,7 +538,7 @@ function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCanc
}
// ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
const toast=useToast();
const _defD = selectedDate || new Date();
const _p = n => String(n).padStart(2,'0');
@@ -552,7 +552,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [allDay,setAllDay]=useState(!!event?.all_day);
const [loc,setLoc]=useState(event?.location||'');
const [desc,setDesc]=useState(event?.description||'');
const [pub,setPub]=useState(event?!!event.is_public:true);
const [pub,setPub]=useState(event?!!event.is_public:!!isToolManager);
const [track,setTrack]=useState(!!event?.track_availability);
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [saving,setSaving]=useState(false);
@@ -628,12 +628,12 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
useEffect(()=>{ mountedRef.current = true; },[]);
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
const groupsRequired=track; // when tracking, groups are required
const groupsRequired = track || !isToolManager; // tracking requires groups; non-managers always require groups
const handle=()=>{
if(!title.trim()) return toast('Title required','error');
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');
if(groupsRequired&&grps.size===0) return toast('Select at least one group','error');
if(ed<sd) return toast('End date cannot be before start date','error');
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error');
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
@@ -645,7 +645,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
setShowScopeModal(false);
setSaving(true);
try{
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
const body={title:title.trim(),eventTypeId:typeId||null,startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st),endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et),allDay,location:loc,description:desc,isPublic:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
const r=event?await api.updateEvent(event.id,{...body,recurringScope:scope}):await api.createEvent(body);
onSave(r.event);
}catch(e){toast(e.message,'error');}finally{setSaving(false);}
@@ -725,14 +725,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
</div>
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
{grps.size===0
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)')
? (groupsRequired?'At least one group required':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`}
</p>
</div>
</FormRow>
{/* Visibility — only shown if groups selected OR tracking */}
{(grps.size>0||track) && (
{/* Visibility — only tool managers can set; regular users always create private events */}
{isToolManager && (grps.size>0||track) && (
<FormRow label="Visibility">
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
@@ -754,7 +754,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
<div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
{event&&isToolManager&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
{event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
</div>
</div>
</div>
@@ -764,7 +764,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
}
// ── Event Detail Modal ────────────────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) {
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response);
const [avail,setAvail]=useState(event.availability||[]);
@@ -800,7 +800,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div>
</div>
<div style={{display:'flex',gap:6,flexShrink:0}}>
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
{(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
</div>
</div>
@@ -1081,6 +1081,10 @@ function parseKeywords(raw) {
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const today=new Date(); today.setHours(0,0,0,0);
const todayRef = useRef(null);
useEffect(()=>{
if(todayRef.current) todayRef.current.scrollIntoView({ block:'start', behavior:'instant' });
},[selectedDate.getFullYear(), selectedDate.getMonth()]);
const terms=parseKeywords(filterKeyword);
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
// Only keyword/availability filters should shift the date window to today-onwards.
@@ -1135,9 +1139,13 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
? `No events — ${MONTHS[m]} ${y} is in the past`
: `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
let todayMarked = false;
return <>{filtered.map(e=>{
const s=new Date(e.start_at);
const end=new Date(e.end_at);
const sDay=new Date(s); sDay.setHours(0,0,0,0);
const isFirstTodayOrFuture = !todayMarked && sDay >= today;
if(isFirstTodayOrFuture) todayMarked = true;
const isPast = !e.all_day && end < now; // event fully ended
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
@@ -1158,7 +1166,7 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
: BELL_ICON
);
return(
<div key={`${e.id}-${e.start_at}`} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
<div key={`${e.id}-${e.start_at}`} ref={isFirstTodayOrFuture ? todayRef : null} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{/* Date column */}
<div style={{width:datW,textAlign:'center',flexShrink:0}}>
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
@@ -1254,7 +1262,12 @@ function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
@@ -1325,7 +1338,12 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]);
useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => {
@@ -1476,7 +1494,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const contentRef = useRef(null);
const load = useCallback(() => {
const ugPromise = isToolManager ? api.getUserGroups() : Promise.resolve({ groups: [] });
const ugPromise = isToolManager ? api.getUserGroups() : api.getMyScheduleGroups();
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
.catch(() => setLoading(false));
@@ -1484,8 +1502,8 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
useEffect(() => { load(); }, [load]);
// Reset scroll to top whenever the selected date or view changes
useEffect(() => { if (contentRef.current) contentRef.current.scrollTop = 0; }, [selDate, view]);
// Reset scroll to top on date/view change; schedule view scrolls to today via ScheduleView's own effect
useEffect(() => { if (contentRef.current && view !== 'schedule') contentRef.current.scrollTop = 0; }, [selDate, view]);
useEffect(() => {
if (!createOpen) return;
@@ -1556,26 +1574,27 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<div style={{ padding:'16px 16px 0' }}>
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
{/* Create button — styled like new-chat-btn */}
{isToolManager && (
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
Create Event
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
{createOpen && (
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
{/* Create button — visible to all users */}
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
Create Event
{isToolManager && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>}
</button>
{createOpen && (
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
{[
['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
...(isToolManager ? [
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}]
].map(([label,action])=>(
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
))}
</div>
)}
</div>
)}
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}],
] : []),
].map(([label,action])=>(
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}}
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
))}
</div>
)}
</div>
</div>
{/* Mini calendar */}
@@ -1691,10 +1710,10 @@ 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('day');}}/>}
{panel === 'eventForm' && isToolManager && !isMobile && (
{panel === 'eventForm' && !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}
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} userId={user.id}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
</div>
)}
@@ -1739,7 +1758,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<MobileGroupManager onClose={() => setMobilePanel(null)}/>
</div>
)}
{panel === 'eventForm' && isToolManager && isMobile && (
{panel === 'eventForm' && isMobile && (
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
<MobileEventForm
event={editingEvent}
@@ -1747,6 +1766,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
eventTypes={eventTypes}
selectedDate={selDate}
isToolManager={isToolManager}
userId={user.id}
onSave={handleSaved}
onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
onDelete={handleDelete} />
@@ -1754,14 +1774,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
)}
{/* Mobile FAB — same position as Messages newchat-fab */}
{isMobile && isToolManager && panel === 'calendar' && (
{isMobile && panel === 'calendar' && (
<div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}>
<button className="newchat-fab" style={{ position:'static' }} onClick={() => setCreateOpen(v=>!v)}>
<button className="newchat-fab" style={{ position:'static' }} onClick={() => {
if (isToolManager) { setCreateOpen(v=>!v); }
else { setPanel('eventForm'); setEditingEvent(null); setFilterKeyword(''); setFilterTypeId(''); }
}}>
<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>
{createOpen && (
{isToolManager && createOpen && (
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
@@ -1779,6 +1802,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<EventDetailModal
event={detailEvent}
isToolManager={isToolManager}
userId={user.id}
onClose={() => setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={(resp) => {

View File

@@ -447,27 +447,50 @@ export default function Chat() {
setActiveGroupId(id);
if (isMobile) {
setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
window.history.pushState({ rosterchirpChatOpen: true }, '');
// The mount sentinel covers the first back gesture — no extra push needed here
}
// Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id));
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
};
// Handle browser back gesture on mobile — return to sidebar instead of exiting
// Establish one history sentinel on mount (mobile only) so back gestures are
// always interceptable without accumulating extra entries.
useEffect(() => {
const handlePopState = (e) => {
if (isMobile && activeGroupId) {
if (window.innerWidth < 768) {
window.history.replaceState({ rc: 'chat' }, '');
}
}, []);
// Handle browser back gesture on mobile — step through the navigation hierarchy:
// chat open → list view for the current page → Messages → exit app
useEffect(() => {
const handlePopState = () => {
if (!isMobile) return;
if (activeGroupId) {
// Close the open chat, stay on the current page's list (chat or groupmessages)
setShowSidebar(true);
setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted
window.history.pushState({ rosterchirpChatOpen: true }, '');
setChatHasText(false);
window.history.pushState({ rc: 'chat' }, '');
return;
}
if (page !== 'chat') {
// On a secondary page (groupmessages / users / groups / schedule / hostpanel)
// — return to the default Messages page
setPage('chat');
window.history.pushState({ rc: 'chat' }, '');
return;
}
// Already at root (Messages list, no chat open) — let the browser handle
// it so the next gesture actually exits the PWA. Don't re-push.
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile, activeGroupId]);
}, [isMobile, activeGroupId, page]);
// Update page title AND PWA app badge with total unread count
useEffect(() => {

View File

@@ -106,6 +106,7 @@ export const api = {
updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
// Schedule Manager
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
getEventTypes: () => req('GET', '/schedule/event-types'),
createEventType: (body) => req('POST', '/schedule/event-types', body),
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),