v0.12.31 multiple UI changes
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.12.30",
|
||||
"version": "0.12.31",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -177,6 +177,20 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── User's own groups (for regular users creating events) ─────────────────────
|
||||
|
||||
router.get('/my-groups', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await query(req.schema, `
|
||||
SELECT ug.id, ug.name FROM user_groups ug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ug.id
|
||||
WHERE ugm.user_id = $1
|
||||
ORDER BY ug.name ASC
|
||||
`, [req.user.id]);
|
||||
res.json({ groups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
@@ -244,31 +258,55 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const groupIds = Array.isArray(userGroupIds) ? userGroupIds : [];
|
||||
if (!itm) {
|
||||
// Regular users: must select at least one group they belong to; event always private
|
||||
if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of groupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
}
|
||||
const effectiveIsPublic = itm ? (isPublic !== false) : false;
|
||||
const r = await queryResult(req.schema, `
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
const eventId = r.rows[0].id;
|
||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||
for (const ugId of groupIds)
|
||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
if (Array.isArray(userGroupIds) && userGroupIds.length > 0)
|
||||
if (groupIds.length > 0)
|
||||
await postEventNotification(req.schema, eventId, req.user.id, false);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
res.json({ event: await enrichEvent(req.schema, event) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.patch('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||
if (!itm) {
|
||||
// Regular users editing their own event: force private, validate group membership
|
||||
isPublic = false;
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of userGroupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
}
|
||||
}
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||
@@ -317,10 +355,12 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
const { recurringScope } = req.body || {};
|
||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||
// Delete this event and all future occurrences with same creator/title
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.12.30}"
|
||||
VERSION="${1:-0.12.31}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="rosterchirp"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.12.30",
|
||||
"version": "0.12.31",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user