conductor

CI task system
git clone git://git.finwo.net/app/conductor
Log | Files | Refs | README | LICENSE

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 };