V0.9.49 UI updated to schedule
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
PROJECT_NAME=jama
|
||||
|
||||
# Image version to run (set by build.sh, or use 'latest')
|
||||
JAMA_VERSION=0.9.48
|
||||
JAMA_VERSION=0.9.49
|
||||
|
||||
# App port — the host port Docker maps to the container
|
||||
PORT=3000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-backend",
|
||||
"version": "0.9.48",
|
||||
"version": "0.9.49",
|
||||
"description": "TeamChat backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -429,15 +429,19 @@ function initDb() {
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`);
|
||||
// Migration: add columns if missing (must run before inserts)
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
|
||||
// Seed built-in event types
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_default, is_protected) VALUES ('Default', '#9ca3af', 1, 1)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, is_protected, default_duration_hrs) VALUES ('Event', '#6366f1', 1, NULL)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Game', '#22c55e', 3.0)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO event_types (name, colour, default_duration_hrs) VALUES ('Practice', '#f59e0b', 1.0)").run();
|
||||
// Migration: add is_protected if missing
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN is_protected INTEGER NOT NULL DEFAULT 0"); } catch(e) {}
|
||||
try { db.exec("ALTER TABLE event_types ADD COLUMN default_duration_hrs REAL"); } catch(e) {}
|
||||
// Ensure built-in types are protected
|
||||
// Ensure built-in types are protected (idempotent)
|
||||
db.prepare("UPDATE event_types SET is_protected = 1 WHERE name IN ('Default', 'Event')").run();
|
||||
// Ensure Game/Practice have correct durations if they already existed without them
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 3.0 WHERE name = 'Game' AND default_duration_hrs IS NULL").run();
|
||||
db.prepare("UPDATE event_types SET default_duration_hrs = 1.0 WHERE name = 'Practice' AND default_duration_hrs IS NULL").run();
|
||||
console.log('[DB] Schedule Manager tables ready');
|
||||
} catch (e) { console.error('[DB] Schedule Manager migration error:', e.message); }
|
||||
|
||||
|
||||
2
build.sh
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.9.48}"
|
||||
VERSION="${1:-0.9.49}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="jama"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jama-frontend",
|
||||
"version": "0.9.48",
|
||||
"version": "0.9.49",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
|
||||
import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import UserFooter from './UserFooter.jsx';
|
||||
|
||||
// ── Utilities ─────────────────────────────────────────────────────────────────
|
||||
const DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
|
||||
@@ -153,21 +154,33 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
const [localTypes,setLocalTypes]=useState(eventTypes);
|
||||
const typeRef=useRef(null);
|
||||
|
||||
// Auto end time when type changes (only for new events)
|
||||
// When event type changes: auto set duration and default group
|
||||
useEffect(()=>{
|
||||
if(!typeId||event) return;
|
||||
if(!sd||!st) return;
|
||||
const typ=localTypes.find(t=>t.id===Number(typeId));
|
||||
if(!typ?.default_duration_hrs||!sd||!st) return;
|
||||
const dur=typ?.default_duration_hrs||1;
|
||||
const start=buildISO(sd,st);
|
||||
setEd(toDateIn(addHours(start,typ.default_duration_hrs)));
|
||||
setEt(toTimeIn(addHours(start,typ.default_duration_hrs)));
|
||||
if(typ.default_user_group_id) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
|
||||
if(start){
|
||||
setEd(toDateIn(addHours(start,dur)));
|
||||
setEt(toTimeIn(addHours(start,dur)));
|
||||
}
|
||||
if(typ?.default_user_group_id&&!event) setGrps(prev=>new Set([...prev,Number(typ.default_user_group_id)]));
|
||||
},[typeId]);
|
||||
|
||||
// Auto-match end date to start date when start date changes
|
||||
// When start date changes: auto-match end date
|
||||
useEffect(()=>{ if(!event) setEd(sd); },[sd]);
|
||||
|
||||
// When start time changes: auto-update end time preserving duration
|
||||
useEffect(()=>{
|
||||
if(!event) setEd(sd);
|
||||
},[sd]);
|
||||
if(!sd||!st) return;
|
||||
const typ=localTypes.find(t=>t.id===Number(typeId));
|
||||
const dur=typ?.default_duration_hrs||1;
|
||||
const start=buildISO(sd,st);
|
||||
if(start){
|
||||
setEd(toDateIn(addHours(start,dur)));
|
||||
setEt(toTimeIn(addHours(start,dur)));
|
||||
}
|
||||
},[st]);
|
||||
|
||||
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
|
||||
@@ -198,7 +211,49 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
|
||||
</div>
|
||||
|
||||
{/* Availability (first — if enabled, groups become required) */}
|
||||
{/* Event Type */}
|
||||
<Row label="Event Type">
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',position:'relative'}} ref={typeRef}>
|
||||
<select className="input" value={typeId} onChange={e=>setTypeId(e.target.value)} style={{flex:1}}>
|
||||
<option value="">Default</option>
|
||||
{localTypes.filter(t=>!t.is_default).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{isToolManager&&<button className="btn btn-secondary btn-sm" style={{flexShrink:0}} onClick={()=>setShowTypeForm(v=>!v)}>{showTypeForm?'Cancel':'+ Type'}</button>}
|
||||
{showTypeForm&&<EventTypePopup userGroups={userGroups} onSave={et=>{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Date/Time */}
|
||||
<Row label="Date & Time">
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
|
||||
<input type="date" className="input" value={sd} onChange={e=>setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
||||
{!allDay&&(
|
||||
<>
|
||||
<select className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:120,flexShrink:0}}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
|
||||
<select className="input" value={et} onChange={e=>setEt(e.target.value)} style={{width:120,flexShrink:0}}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<input type="date" className="input" value={ed} onChange={e=>setEd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div style={{display:'flex',alignItems:'center',gap:16}}>
|
||||
<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>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Availability */}
|
||||
<Row label="Availability">
|
||||
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
|
||||
<input type="checkbox" checked={track} onChange={e=>{setTrack(e.target.checked);if(!e.target.checked) setPub(true);}}/>
|
||||
@@ -237,42 +292,6 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Date/Time */}
|
||||
<Row label="Date & Time">
|
||||
<div style={{display:'flex',flexDirection:'column',gap:8}}>
|
||||
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
|
||||
<input type="date" className="input" value={sd} onChange={e=>setSd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
||||
{!allDay&&(
|
||||
<>
|
||||
<select className="input" value={st} onChange={e=>setSt(e.target.value)} style={{width:120,flexShrink:0}}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<span style={{color:'var(--text-tertiary)',fontSize:13,flexShrink:0}}>to</span>
|
||||
<select className="input" value={et} onChange={e=>setEt(e.target.value)} style={{width:120,flexShrink:0}}>
|
||||
{TIME_SLOTS.map(s=><option key={s.value} value={s.value}>{s.label}</option>)}
|
||||
</select>
|
||||
<input type="date" className="input" value={ed} onChange={e=>setEd(e.target.value)} style={{width:150,flexShrink:0}}/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Event Type */}
|
||||
<Row label="Event Type">
|
||||
<div style={{display:'flex',gap:8,alignItems:'center',position:'relative'}} ref={typeRef}>
|
||||
<select className="input" value={typeId} onChange={e=>setTypeId(e.target.value)} style={{flex:1}}>
|
||||
<option value="">Default</option>
|
||||
{localTypes.filter(t=>!t.is_default).map(t=><option key={t.id} value={t.id}>{t.name}</option>)}
|
||||
</select>
|
||||
{isToolManager&&<button className="btn btn-secondary btn-sm" style={{flexShrink:0}} onClick={()=>setShowTypeForm(v=>!v)}>{showTypeForm?'Cancel':'+ Type'}</button>}
|
||||
{showTypeForm&&<EventTypePopup userGroups={userGroups} onSave={et=>{setLocalTypes(p=>[...p,et]);setShowTypeForm(false);}} onClose={()=>setShowTypeForm(false)}/>}
|
||||
</div>
|
||||
</Row>
|
||||
|
||||
{/* Location */}
|
||||
<Row label="Location">
|
||||
<input className="input" placeholder="Add location" value={loc} onChange={e=>setLoc(e.target.value)}/>
|
||||
@@ -298,11 +317,19 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
|
||||
const toast=useToast();
|
||||
const [myResp,setMyResp]=useState(event.my_response);
|
||||
const [avail,setAvail]=useState(event.availability||[]);
|
||||
// Sync when parent reloads event after availability change
|
||||
useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]);
|
||||
const counts={going:0,maybe:0,not_going:0};
|
||||
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
|
||||
|
||||
const handleResp=async resp=>{
|
||||
try{if(myResp===resp){await api.deleteAvailability(event.id);setMyResp(null);}else{await api.setAvailability(event.id,resp);setMyResp(resp);}onAvailabilityChange?.();}catch(e){toast(e.message,'error');}
|
||||
const prev=myResp;
|
||||
const next=myResp===resp?null:resp;
|
||||
setMyResp(next); // optimistic update
|
||||
try{
|
||||
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);}
|
||||
onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list
|
||||
}catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error
|
||||
};
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
@@ -521,7 +548,7 @@ export default function SchedulePage({ isToolManager, isMobile }) {
|
||||
{/* Left panel — matches sidebar width */}
|
||||
{!isMobile && (
|
||||
<div style={{ width:SIDEBAR_W, flexShrink:0, borderRight:'1px solid var(--border)', display:'flex', flexDirection:'column', background:'var(--surface)', overflow:'hidden' }}>
|
||||
<div style={{ padding:'16px 16px 8px', borderBottom:'1px solid var(--border)' }}>
|
||||
<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 */}
|
||||
@@ -550,9 +577,12 @@ export default function SchedulePage({ isToolManager, isMobile }) {
|
||||
</div>
|
||||
|
||||
{/* Mini calendar */}
|
||||
<div style={{ padding:16 }}>
|
||||
<div style={{ padding:'8px 16px 16px' }}>
|
||||
<div className="section-label" style={{ marginBottom:8 }}>Filter Events</div>
|
||||
<MiniCalendar selected={selDate} onChange={d=>{setSelDate(d);setPanel('calendar');}} eventDates={eventDates}/>
|
||||
</div>
|
||||
<div style={{ flex:1 }}/>
|
||||
<UserFooter />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -637,7 +667,11 @@ export default function SchedulePage({ isToolManager, isMobile }) {
|
||||
isToolManager={isToolManager}
|
||||
onClose={() => setDetailEvent(null)}
|
||||
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
|
||||
onAvailabilityChange={() => openDetail(detailEvent)}
|
||||
onAvailabilityChange={(resp) => {
|
||||
// Update the list so the "awaiting response" dot disappears immediately
|
||||
setEvents(prev => prev.map(e => e.id === detailEvent.id ? {...e, my_response: resp} : e));
|
||||
openDetail(detailEvent);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -5,15 +5,7 @@ import { api, parseTS } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
import './Sidebar.css';
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
return [dark, setDark];
|
||||
}
|
||||
import UserFooter from './UserFooter.jsx';
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
||||
@@ -52,14 +44,10 @@ function formatTime(dateStr) {
|
||||
}
|
||||
|
||||
export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifications, unreadGroups = new Map(), onNewChat, onProfile, onUsers, onSettings: onOpenSettings, onBranding, onGroupManager, onGroupsUpdated, isMobile, onAbout, onHelp, onlineUserIds = new Set(), features = {} }) {
|
||||
const { user, logout } = useAuth();
|
||||
const { user } = useAuth();
|
||||
const { connected } = useSocket();
|
||||
const toast = useToast();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const settings = useAppSettings();
|
||||
const [dark, setDark] = useTheme();
|
||||
const menuRef = useRef(null);
|
||||
const footerBtnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
@@ -92,7 +80,6 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
});
|
||||
|
||||
const getNotifCount = (groupId) => notifications.filter(n => n.groupId === groupId).length;
|
||||
const handleLogout = async () => { await logout(); };
|
||||
|
||||
const GroupItem = ({ group }) => {
|
||||
const notifs = getNotifCount(group.id);
|
||||
@@ -189,62 +176,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button ref={footerBtnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
className="btn-icon"
|
||||
onClick={() => setDark(d => !d)}
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
style={{ flexShrink: 0, padding: 8 }}
|
||||
>
|
||||
{dark ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div ref={menuRef} className="footer-menu">
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp && onHelp(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Help
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout && onAbout(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
About
|
||||
</button>
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UserFooter onProfile={onProfile} onHelp={onHelp} onAbout={onAbout} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
frontend/src/components/UserFooter.jsx
Normal file
88
frontend/src/components/UserFooter.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
function useTheme() {
|
||||
const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
|
||||
localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
return [dark, setDark];
|
||||
}
|
||||
|
||||
export default function UserFooter({ onProfile, onHelp, onAbout }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [dark, setDark] = useTheme();
|
||||
const menuRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
const handler = (e) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target) &&
|
||||
btnRef.current && !btnRef.current.contains(e.target)) {
|
||||
setShowMenu(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [showMenu]);
|
||||
|
||||
const handleLogout = async () => { await logout(); };
|
||||
|
||||
return (
|
||||
<div className="sidebar-footer">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<button ref={btnRef} className="user-footer-btn" style={{ flex: 1 }} onClick={() => setShowMenu(!showMenu)}>
|
||||
<Avatar user={user} size="sm" />
|
||||
<div className="flex-col flex-1 overflow-hidden" style={{ textAlign: 'left' }}>
|
||||
<span className="font-medium text-sm truncate">{user?.display_name || user?.name}</span>
|
||||
<span className="text-xs truncate" style={{ color: 'var(--text-secondary)' }}>{user?.role}</span>
|
||||
</div>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button className="btn-icon" onClick={() => setDark(d => !d)} title={dark ? 'Light mode' : 'Dark mode'} style={{ flexShrink: 0, padding: 8 }}>
|
||||
{dark ? (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showMenu && (
|
||||
<div ref={menuRef} className="footer-menu">
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile?.(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onHelp?.(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>
|
||||
Help
|
||||
</button>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onAbout?.(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
About
|
||||
</button>
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user