/* ───────────── API ───────────── */ const H = { "content-type":"application/json" }; const api = { async access(code){ const r=await fetch("/api/access",{method:"POST",headers:H,body:JSON.stringify({code})}); const d=await r.json(); if(!r.ok) throw new Error(d.error||"Помилка"); return d; }, events: ()=> fetch("/api/events").then(r=>r.json()), members: ()=> fetch("/api/members").then(r=>r.json()), registrations: ()=> fetch("/api/registrations").then(r=>r.json()), register: (event_id,member_id)=> fetch("/api/registrations",{method:"POST",headers:H,body:JSON.stringify({event_id,member_id})}), unregister: (event_id,member_id)=> fetch("/api/registrations",{method:"DELETE",headers:H,body:JSON.stringify({event_id,member_id})}), addEvent: (data)=> fetch("/api/events",{method:"POST",headers:H,body:JSON.stringify(data)}), deleteEvent: (id,member_id)=> fetch("/api/events",{method:"DELETE",headers:H,body:JSON.stringify({id,member_id})}), createMember: (data)=> fetch("/api/members",{method:"POST",headers:H,body:JSON.stringify(data)}), setMemberRole: (id,role,member_id,code)=> fetch("/api/members",{method:"PATCH",headers:H,body:JSON.stringify({id,role,member_id,code})}), deleteMember: (id,member_id,code)=> fetch("/api/members",{method:"DELETE",headers:H,body:JSON.stringify({id,member_id,code})}), }; /* ───────────── топо ───────────── */ function Topo({dark,count=7,seed=0}){ const stroke = dark?C.topoDark:C.topoLight; const loops=[]; for(let i=0;i); return {loops}; } function Logo(){ return ; } function Plus({color="#F4F2EA"}){ return ; } function Trash({color="#E0532A"}){ return ; } function Icon({name,size=18,color=C.ink,strokeW=1.8}){ const p={fill:"none",stroke:color,strokeWidth:strokeW,strokeLinecap:"round",strokeLinejoin:"round"}; const paths={ list:<>, pin:<>, cal:<>, team:<>, }; return {paths[name]}; } /* ═════════════ ROOT ═════════════ */ function App(){ const [access,setAccess] = useState(()=> localStorage.getItem("ascania_access")==="1"); const [code,setCode] = useState(()=> localStorage.getItem("ascania_code")||""); const [members,setMembers] = useState([]); const [user,setUser] = useState(()=>{ try{return JSON.parse(localStorage.getItem("ascania_user"))}catch(e){return null} }); const grant = (data,c) => { localStorage.setItem("ascania_access","1"); localStorage.setItem("ascania_code",c); setCode(c); setMembers(data.members||[]); setAccess(true); }; const pick = (m) => { localStorage.setItem("ascania_user", JSON.stringify(m)); setUser(m); }; const logout = () => { localStorage.removeItem("ascania_user"); setUser(null); }; // зміна учасника const exitTeam = () => { localStorage.removeItem("ascania_user"); localStorage.removeItem("ascania_access"); localStorage.removeItem("ascania_code"); setUser(null); setAccess(false); }; if(!access) return ; if(!user) return ; return
; } /* ───────────── код команди ───────────── */ function CodeScreen({onOk}){ const [code,setCode]=useState(""); const [err,setErr]=useState(""); const [busy,setBusy]=useState(false); const submit = async () => { if(!code.trim()) return; setBusy(true); setErr(""); try { onOk(await api.access(code), code.trim()); } catch(e){ setErr(e.message); setBusy(false); } }; return (
ASCANIA
CLIMBING TEAM
Код команди
Введи спільний код команди один раз — далі застосунок тебе запам'ятає.
setCode(e.target.value)} onKeyDown={e=>e.key==="Enter"&&submit()} /> {err &&
{err}
}
); } /* ───────────── вибір учасника ───────────── */ function PickScreen({members,code,onPick,onExit}){ const [list,setList]=useState(members); const [reg,setReg]=useState(false); const [name,setName]=useState(""); const [role,setRole]=useState("Спортсмен"); const [busy,setBusy]=useState(false); const [err,setErr]=useState(""); useEffect(()=>{ if(!members.length){ api.members().then(d=>setList(d.members||[])); } },[]); const data = members.length?members:list; const submit = async () => { if(!name.trim()) return; setBusy(true); setErr(""); const res=await api.createMember({name:name.trim(),role,code}); const d=await res.json(); if(res.ok){ onPick(d.member); } else { setErr(d.error||"Не вдалося зареєструватися"); setBusy(false); } }; return (
вхід
{reg?"Реєстрація":"Хто ти?"}
{reg ? (
Додай себе у склад команди — це разово.
setName(e.target.value)} placeholder="напр. Іван Петренко" onKeyDown={e=>e.key==="Enter"&&submit()}/>
{["Спортсмен","Медіа"].map(r=>( ))}
Роль капітана / тренера призначає капітан окремо.
{err &&
{err}
}
) : ( <>
{data.map(m=>( ))}
)}
); } /* ───────────── головний ───────────── */ function Main({user,code,logout}){ const [tab,setTab]=useState("events"); const [events,setEvents]=useState([]); const [members,setMembers]=useState([]); const [regs,setRegs]=useState([]); const [loading,setLoading]=useState(true); const [toast,setToast]=useState(null); const [adding,setAdding]=useState(false); const canManage = user.role==="Капітан"||user.role==="Тренер"; const flash=(msg,kind)=>{ setToast({msg,kind}); clearTimeout(window.__t); window.__t=setTimeout(()=>setToast(null),2200); }; const load = async () => { try { const [e,m,r] = await Promise.all([api.events(),api.members(),api.registrations()]); setEvents(e.events||[]); setMembers(m.members||[]); setRegs(r.registrations||[]); } catch(err){ flash("Помилка завантаження","off"); } setLoading(false); }; useEffect(()=>{ load(); },[]); const memberById = useMemo(()=>Object.fromEntries(members.map(m=>[m.id,m])),[members]); const going = ev => regs.filter(r=>r.event_id===ev.id).length; const myReg = useMemo(()=>new Set(regs.filter(r=>r.member_id===user.id).map(r=>r.event_id)),[regs,user.id]); const rosterOf = ev => regs.filter(r=>r.event_id===ev.id).map(r=>memberById[r.member_id]).filter(Boolean); const toggle = async (ev) => { const isReg = myReg.has(ev.id); if(isReg){ setRegs(p=>p.filter(r=>!(r.event_id===ev.id&&r.member_id===user.id))); const res=await api.unregister(ev.id,user.id); if(!res.ok){ load(); } flash("Запис скасовано · "+ev.title,"off"); } else { const res=await api.register(ev.id,user.id); const d=await res.json(); if(res.ok){ setRegs(p=>[...p,{event_id:ev.id,member_id:user.id,status:"Йду"}]); flash("Ти у складі · "+ev.title,"on"); } else { flash(d.error||"Не вдалося","off"); } } }; const addEvent = async (form) => { const res=await api.addEvent({...form, member_id:user.id}); const d=await res.json(); if(res.ok){ setAdding(false); await load(); setTab("events"); flash("Захід додано · "+form.title.trim(),"on"); } else { flash(d.error||"Не вдалося створити","off"); } }; const removeEvent = async (ev) => { if(!window.confirm("Видалити захід «"+ev.title+"»? Усі записи на нього теж зникнуть.")) return; const res=await api.deleteEvent(ev.id,user.id); if(res.ok){ await load(); flash("Захід видалено · "+ev.title,"off"); } else { const d=await res.json(); flash(d.error||"Не вдалося видалити","off"); } }; const changeRole = async (m,role) => { const res=await api.setMemberRole(m.id,role,user.id,code); if(res.ok){ await load(); flash(m.name+" → "+role,"on"); } else { const d=await res.json(); flash(d.error||"Не вдалося","off"); } }; const removeMember = async (m) => { if(!window.confirm("Видалити учасника «"+m.name+"»? Його записи теж зникнуть.")) return; const res=await api.deleteMember(m.id,user.id,code); if(res.ok){ await load(); flash("Учасника видалено","off"); } else { const d=await res.json(); flash(d.error||"Не вдалося","off"); } }; return (
{loading ? : <> {tab==="events" && setAdding(true)} canAdd={canManage} onDelete={removeEvent}/>} {tab==="map" && } {tab==="cal" && } {tab==="team" && } }
{toast &&
{toast.msg}
} {adding && setAdding(false)} onSave={addEvent}/>} ); } function Frame({children}){ return
{children}
; } function Loading(){ return
Завантаження…
; } const TITLES={events:{k:"афіша",t:"Заходи"},map:{k:"географія",t:"Карта місць"},cal:{k:"розклад",t:"Календар"},team:{k:"склад",t:"Команда"}}; function Header({tab,myCount}){ const x=TITLES[tab]; return
ASCANIA
CLIMBING TEAM
я їду · {myCount}
{x.k}
{x.t}
; } /* ───────────── заходи ───────────── */ function Events({events,myReg,toggle,going,rosterOf,onAdd,canAdd,onDelete}){ const [mine,setMine]=useState(false); const all=[...events].sort((a,b)=>parse(a.date)-parse(b.date)); const list=mine?all.filter(ev=>myReg.has(ev.id)):all; return
setMine(v==="mine")} options={[["all","Усі"],["mine","Мої · "+all.filter(e=>myReg.has(e.id)).length]]}/> {canAdd && }
{list.map(ev=>( ))} {list.length===0 &&
{mine?"Ти ще нікуди не записаний. Подивись вкладку «Усі».":"Поки немає заходів."}
} {canAdd && !mine && }
; } function Segmented({value,onChange,options}){ return
{options.map(([v,label])=>{ const a=value===v; return ( );})}
; } function EventCard({ev,registered,toggle,going,canManage,onDelete}){ const full=going>=ev.capacity && !registered; const col=typeColor(ev.type); return
{ev.type} {ev.dist && {ev.dist}}
{ev.title}
{registered && } {canManage && }
{fmtDate(ev.date)}{ev.time?" · "+ev.time:""} {ev.address && {ev.address}}
{ev.gear &&
Взяти: {ev.gear}
}
; } function Roster({members}){ return
Хто їде · {members.length}
{members.length===0 ?
Поки нікого. Будь першим — натисни «Записатися».
:
{members.map(m=>(
{initials(m.name)}
{m.name.split(" ")[0]}
))}
}
; } function Capacity({going,cap}){ const pct=Math.min(100,(going/cap)*100); const tight=going/cap>=0.85; return
{going}/{cap} {tight?"майже заповнено":"їдуть"}
; } function Meta({icon,children}){ return {children}; } function Check(){ return
; } /* ───────────── форма ───────────── */ function AddModal({onClose,onSave}){ const [f,setF]=useState({title:"",type:"Велозаїзд",date:"",time:"08:00",address:"",capacity:"18",dist:"",gear:"",notes:""}); const set=k=>e=>setF(p=>({...p,[k]:e.target.value})); const valid=f.title.trim()&&f.date; return
Новий захід
{TYPE_SUGGEST.map(t=>)}
{!valid &&
Потрібні назва та дата
}
; } function Field({label,children,style}){ return
{label}
{children}
; } /* ───────────── карта ───────────── */ function MapScreen({events,going}){ const locs = useMemo(()=>{ const m={}; events.forEach(e=>{ if(!e.address) return; if(!m[e.address]){const c=coordsFor(e.address); m[e.address]={address:e.address,x:c.x,y:c.y,events:[]};} m[e.address].events.push(e); }); return m; },[events]); const keys=Object.keys(locs); const [sel,setSel]=useState(null); useEffect(()=>{ if(!sel && keys.length) setSel(keys[0]); },[keys.join("|")]); return
{[20,40,60,80].map(p=>)} {[20,40,60,80].map(p=>)} {keys.map(k=>{ const loc=locs[k]; const active=sel===k; return ( );})} {keys.length===0 &&
Немає заходів з адресою
}
{sel && locs[sel] ? <>
{locs[sel].address}
{locs[sel].events.map(ev=>(
{ev.title}
{fmtDate(ev.date)}{ev.time?" · "+ev.time:""}
{going(ev)}/{ev.capacity}
))}
:
Натисни на точку, щоб побачити заходи.
}
; } /* ───────────── календар ───────────── */ function CalScreen({events,myReg,toggle,going}){ const todayStr=todayISO(); const init=()=>{ const d=parse(todayStr); return {y:d.getFullYear(),m:d.getMonth()}; }; const [view,setView]=useState(init); const [day,setDay]=useState(todayStr); const byDate=useMemo(()=>{const map={};events.forEach(e=>{(map[e.date]||(map[e.date]=[])).push(e);});return map;},[events]); const first=new Date(view.y,view.m,1); const startPad=(first.getDay()+6)%7; const daysIn=new Date(view.y,view.m+1,0).getDate(); const cells=[]; for(let i=0;iview.y+"-"+String(view.m+1).padStart(2,"0")+"-"+String(d).padStart(2,"0"); const shift=dir=>setView(v=>{let m=v.m+dir,y=v.y;if(m<0){m=11;y--;}if(m>11){m=0;y++;}return{y,m};}); const dayEvents=byDate[day]||[]; return
{MONTHS_NOM[view.m]} {view.y}
{WD_SHORT.map(w=>
{w}
)} {cells.map((d,i)=>{ if(!d)return
; const id=iso(d); const evs=byDate[id]||[]; const isToday=id===todayStr; const isSel=id===day; return ; })}
{fmtDate(day)}{day===todayStr?" · сьогодні":""}
{dayEvents.length===0 ?
На цей день нічого не заплановано.
Обери день із кольоровою крапкою.
:
{dayEvents.map(ev=>{ const registered=myReg.has(ev.id); return (
{ev.title} {ev.time}
{ev.address||"—"} · {going(ev)}/{ev.capacity}
);})}
}
; } const navBtn={width:34,height:34,borderRadius:10,background:C.paper,display:"flex",alignItems:"center",justifyContent:"center"}; function Chevron({dir}){ return ; } /* ───────────── команда ───────────── */ const ROLES=["Капітан","Тренер","Спортсмен","Медіа"]; function Team({events,members,regs,me,logout,canManage,onRole,onRemove}){ const [manage,setManage]=useState(false); const counts=useMemo(()=>{const c={};members.forEach(m=>c[m.id]=0);regs.forEach(r=>c[r.member_id]=(c[r.member_id]||0)+1);return c;},[members,regs]); const sorted=[...members].sort((a,b)=>(a.id===me.id?-1:b.id===me.id?1:0)); return
{[[String(members.length),"у команді"],[String(events.length),plural(events.length,["захід","заходи","заходів"])],[String(counts[me.id]||0),"у тебе"]].map(([n,l])=>(
{n}
{l}
))}
{canManage && (
)}
{sorted.map(m=>{ const you=m.id===me.id; const editable=manage&&canManage&&!you; return (
{initials(m.name)}
{m.name}{you&&ТИ}
{m.role}
{editable ? ( ) : (
{counts[m.id]||0}
{plural(counts[m.id]||0,["запис","записи","записів"])}
)}
{editable && (
{ROLES.map(r=>( ))}
)}
);})}
; } /* ───────────── таб-бар ───────────── */ function TabBar({tab,setTab}){ const tabs=[{id:"events",label:"Афіша",icon:"list"},{id:"map",label:"Карта",icon:"pin"},{id:"cal",label:"Календар",icon:"cal"},{id:"team",label:"Команда",icon:"team"}]; return
{tabs.map(t=>{ const active=tab===t.id; return ( );})}
; } ReactDOM.createRoot(document.getElementById("root")).render();