layout.tsx (3592B)
1 // web/src/component/layout.tsx - chrome, badges, paging and failure 2 3 import m from 'mithril'; 4 import * as auth from '../util/auth'; 5 import { ApiError } from '../util/api'; 6 import { ago, stamp } from '../util/format'; 7 8 export { session } from '../util/auth'; 9 10 // Mirrors canManageProject on the server. 11 export function manages(project: { owner_id?: string | null } | null): boolean { 12 const user = auth.session.user; 13 if (!user || !project) return false; 14 if (user.role === 'admin') return true; 15 return project.owner_id != null && project.owner_id === user.id; 16 } 17 18 const Link = (href: string, label: string) => { 19 const at = m.route.get() ?? ''; 20 return <m.route.Link href={href} class={at.startsWith(href) ? 'active' : ''}>{label}</m.route.Link>; 21 }; 22 23 export const Chrome: m.Component = { 24 view(vnode) { 25 return [ 26 <header> 27 <nav> 28 <span class="brand">conductor</span> 29 {Link('/jobs', 'jobs')} 30 {Link('/projects', 'projects')} 31 {auth.session.user ? Link('/workers', 'workers') : null} 32 {auth.session.user?.role === 'admin' ? Link('/users', 'users') : null} 33 {auth.session.user ? Link('/sessions', 'sessions') : null} 34 </nav> 35 <div class="who"> 36 {auth.session.user 37 ? [ 38 <span>{auth.session.user.username}</span>, 39 <button class="link" onclick={() => auth.signOut()}>sign out</button>, 40 ] 41 : <button class="link" onclick={() => auth.signIn()}>sign in</button>} 42 </div> 43 </header>, 44 <main>{vnode.children}</main>, 45 ]; 46 }, 47 }; 48 49 export const Badge: m.Component<{ state: string }> = { 50 view: ({ attrs }) => <span class={`badge s-${attrs.state}`}>{attrs.state}</span>, 51 }; 52 53 export const Loading: m.Component = { 54 view: () => <p class="muted">loading</p>, 55 }; 56 57 export const Failed: m.Component<{ error: unknown }> = { 58 view({ attrs }) { 59 const e = attrs.error as ApiError; 60 const unauthenticated = e?.name === 'NotAuthenticated'; 61 return ( 62 <div class="error"> 63 <strong>{unauthenticated ? 'Not signed in' : (e?.name ?? 'Error')}</strong> 64 <p>{e?.message ?? String(attrs.error)}</p> 65 {unauthenticated ? <button onclick={() => auth.signIn()}>sign in</button> : null} 66 </div> 67 ); 68 }, 69 }; 70 71 // A polled entity that vanishes has to say so, rather than the region it 72 // was in quietly disappearing. 73 export const Gone: m.Component<{ what: string }> = { 74 view: ({ attrs }) => ( 75 <div class="error"> 76 <strong>Gone</strong> 77 <p>{`That ${attrs.what} no longer exists, or is no longer yours to see.`}</p> 78 </div> 79 ), 80 }; 81 82 export const When: m.Component<{ at: number | null }> = { 83 view: ({ attrs }) => <span class="when" title={stamp(attrs.at)}>{ago(attrs.at)}</span>, 84 }; 85 86 type PagerAttrs = { offset: number; total: number; limit: number; onmove: (offset: number) => void }; 87 88 export const Pager: m.Component<PagerAttrs> = { 89 view({ attrs }) { 90 const { offset, total, limit } = attrs; 91 if (total <= limit) return null; 92 93 const last = Math.min(offset + limit, total); 94 return ( 95 <div class="pager"> 96 <button disabled={offset === 0} onclick={() => attrs.onmove(Math.max(0, offset - limit))}>previous</button> 97 <span>{`${total === 0 ? 0 : offset + 1} to ${last} of ${total}`}</span> 98 <button disabled={last >= total} onclick={() => attrs.onmove(offset + limit)}>next</button> 99 </div> 100 ); 101 }, 102 }; 103 104 export const Empty: m.Component<{ what: string }> = { 105 view: ({ attrs }) => <p class="muted">{`No ${attrs.what} to show.`}</p>, 106 };