tasks-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 9b0d0f7b9a93
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone f6f2112e77bc8c011e38c83a
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
src/BoardPage.tsx
// A board: joins it on this device (the link is the permission), claims it when opened with the admin
// link, then shows the board, list or calendar view with search and filters, and a card when one is open.
import { useEffect, useState } from "react";
import { BoardContext, joinBoard, claimBoard, useBoardState, useBoard } from "./lib/board";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { rememberBoard } from "./lib/mine";
import { NO_FILTER, NOBODY, filterCount, type DueFilter, type Filter } from "./lib/filter";
import { Icon, Popover, share, toast, download } from "./ui";
import { Kanban } from "./Kanban";
import { TableView } from "./TableView";
import { CalendarView } from "./CalendarView";
import { CardModal } from "./CardModal";
import { SettingsPanel, LabelsPanel, PeoplePanel, ArchivePanel } from "./Panels";
import { initials } from "./CardTile";
import { SITE_URL } from "../functions/lib/config";
import { COLORS, boardCsv, exportBoard, fileName } from "../functions/lib/tasks";

type View = "board" | "list" | "calendar";
type Panel = "settings" | "labels" | "people" | "archive" | null;
let cardPushed = false;

export function BoardPage({ boardId, cardId, admin }: { boardId: string; cardId: string | null; admin: boolean }) {
  const [state, setState] = useState<{ status: "joining" | "missing" | "removed" | "ready" | "error"; uid?: string; error?: string; claim?: "ok" | "bad" }>({ status: "joining" });
  useEffect(() => {
    let off = false;
    (async () => {
      try {
        const { uid, ok, removed } = await joinBoard(boardId);
        if (!ok) { if (!off) setState({ status: removed ? "removed" : "missing" }); return; }
        let claim: "ok" | "bad" | undefined;
        const key = new URLSearchParams(location.hash.slice(1)).get("k");
        if (key) {
          claim = (await claimBoard(boardId, uid, key)) ? "ok" : "bad";
          if (claim === "ok") rememberBoard({ id: boardId, key });
          // Drop the key from the address bar so it is not shared by accident.
          history.replaceState(null, "", `/b/${boardId}${admin ? "/admin" : ""}`);
        }
        if (!off) setState({ status: "ready", uid, claim });
      } catch (err) {
        if (!off) setState({ status: "error", error: explain(err) });
      }
    })();
    return () => { off = true; };
  }, [boardId]); // eslint-disable-line react-hooks/exhaustive-deps

  if (state.status === "joining") return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Opening the board...</div></div>;
  if (state.status === "missing" || state.status === "removed") return (
    <div className="wrap page-pad narrow">
      <h1 className="h1">{state.status === "removed" ? "You were removed from this board." : "This board does not exist."}</h1>
      <p className="lede" style={{ marginTop: 14 }}>{state.status === "removed" ? "The board's owner removed this device. Ask them if you think that was a mistake." : "Check the link you were sent: board links are long and every character counts. The board may also have been deleted."}</p>
      <a className="btn" style={{ marginTop: 24 }} href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a new board</a>
    </div>
  );
  if (state.status === "error") return <div className="wrap page-pad narrow"><p className="notice notice-error">{state.error}</p></div>;
  return <Loaded boardId={boardId} uid={state.uid!} cardId={cardId} admin={admin} claim={state.claim} />;
}

function Loaded({ boardId, uid, cardId, admin, claim }: { boardId: string; uid: string; cardId: string | null; admin: boolean; claim?: "ok" | "bad" }) {
  const b = useBoardState(boardId, uid);
  return (
    <BoardContext.Provider value={b}>
      <BoardScreen cardId={cardId} admin={admin} claim={claim} />
    </BoardContext.Provider>
  );
}

function readView(boardId: string): View {
  try {
    const v = localStorage.getItem(`tasks:view:${boardId}`);
    return v === "list" || v === "calendar" ? v : "board";
  } catch { return "board"; }
}

function BoardScreen({ cardId, admin, claim }: { cardId: string | null; admin: boolean; claim?: "ok" | "bad" }) {
  const b = useBoard();
  const [view, setViewState] = useState<View>(() => readView(b.boardId));
  const [filter, setFilter] = useState<Filter>(NO_FILTER);
  const [panel, setPanel] = useState<Panel>(admin ? "settings" : null);
  const [isNew, setIsNew] = useState(() => { try { return localStorage.getItem(`tasks:new:${b.boardId}`) === "1"; } catch { return false; } });
  const setView = (v: View) => { setViewState(v); try { localStorage.setItem(`tasks:view:${b.boardId}`, v); } catch { /* fine */ } };

  useEffect(() => {
    if (!b.board) return;
    document.title = `${b.board.title} | Tasks`;
    rememberBoard({ id: b.boardId, title: b.board.title });
  }, [b.board?.title]); // eslint-disable-line react-hooks/exhaustive-deps
  useEffect(() => {
    if (claim === "ok") toast("This device can now manage the board.");
    if (claim === "bad") toast("That admin link did not work. Check that you copied all of it.");
  }, [claim]);

  if (!b.ready) return <div className="wrap page-pad"><div className="loading"><span className="spinner" /> Loading the board...</div></div>;
  if (!b.board) return (
    <div className="wrap page-pad narrow">
      <h1 className="h1">This board was deleted.</h1>
      <a className="btn" style={{ marginTop: 24 }} href="/" onClick={(e) => { e.preventDefault(); navigate("/"); }}>Make a new board</a>
    </div>
  );

  const url = `${SITE_URL}/b/${b.boardId}`;
  const openCard = (id: string) => { cardPushed = true; navigate(`/b/${b.boardId}/c/${id}`, { keepScroll: true }); };
  // Closing goes back when this page opened the card, so the back button does not reopen it.
  const closeCard = () => {
    if (cardPushed) { cardPushed = false; history.back(); } else navigate(`/b/${b.boardId}`, { keepScroll: true, replace: true });
  };
  const dismissNew = () => { try { localStorage.removeItem(`tasks:new:${b.boardId}`); } catch { /* fine */ } setIsNew(false); };
  const exportData = () => ({ board: { ...b.board!, id: b.boardId }, lists: b.lists, cards: b.cards, labels: b.labels, members: b.members });

  return (
    <div className={`board-page view-${view}`}>
      <header className="board-head">
        <div className="board-head-inner">
          <div className="board-title-block">
            <h1 className="board-title">{b.board.title}</h1>
            {b.board.archived && <span className="chip">Archived, read only</span>}
          </div>
          <div className="board-actions">
            <YouPicker />
            <button className="btn btn-sm" type="button" onClick={() => share(url, b.board!.title)}><Icon name="link" size={16} /> Share</button>
            <Popover className="icon-btn" label="Board menu" button={<Icon name="more" />}>
              {(close) => (
                <div className="menu">
                  <button type="button" onClick={() => { setPanel("settings"); close(); }}><Icon name="lock" /> Board settings{b.isOwner ? "" : "..."}</button>
                  <button type="button" onClick={() => { setPanel("labels"); close(); }}><Icon name="tag" /> Labels</button>
                  <button type="button" onClick={() => { setPanel("people"); close(); }}><Icon name="user" /> People</button>
                  <button type="button" onClick={() => { setPanel("archive"); close(); }}><Icon name="archive" /> Archived items</button>
                  <hr />
                  <button type="button" onClick={() => { download(fileName(b.board!.title, "json"), JSON.stringify(exportBoard(exportData()), null, 2), "application/json"); close(); }}><Icon name="download" /> Export JSON</button>
                  <button type="button" onClick={() => { download(fileName(b.board!.title, "csv"), boardCsv(exportData()), "text/csv"); close(); }}><Icon name="download" /> Export CSV</button>
                </div>
              )}
            </Popover>
          </div>
        </div>
        {isNew && (
          <div className="notice notice-ok invite">
            <span>Board ready. Send the link to your team: anyone with it can edit, no account needed. Keep your admin link private.</span>
            <span className="row" style={{ gap: 8 }}>
              <button className="btn btn-sm" type="button" onClick={() => share(url, b.board!.title)}>Share link</button>
              <button className="btn btn-sm btn-line" type="button" onClick={() => setPanel("settings")}>Admin link</button>
              <button className="btn btn-sm btn-quiet" type="button" onClick={dismissNew}>Dismiss</button>
            </span>
          </div>
        )}
        {b.board.archived && (
          <p className="notice archived-note">This board is archived, so nobody can change it. {b.isOwner ? <button type="button" className="link-btn" onClick={() => void b.actions.setBoardArchived(false)}>Restore the board</button> : "Its owner can restore it."}</p>
        )}
        <Toolbar view={view} setView={setView} filter={filter} setFilter={setFilter} />
      </header>

      <main className="board-main">
        {view === "board" && <Kanban filter={filter} onOpen={openCard} />}
        {view === "list" && <TableView filter={filter} onOpen={openCard} />}
        {view === "calendar" && <CalendarView filter={filter} onOpen={openCard} />}
      </main>

      {cardId && <CardModal cardId={cardId} onClose={closeCard} key={cardId} />}
      {panel === "settings" && <SettingsPanel onClose={() => setPanel(null)} />}
      {panel === "labels" && <LabelsPanel onClose={() => setPanel(null)} />}
      {panel === "people" && <PeoplePanel onClose={() => setPanel(null)} />}
      {panel === "archive" && <ArchivePanel onClose={() => setPanel(null)} onOpen={(id) => { setPanel(null); openCard(id); }} />}
    </div>
  );
}

function YouPicker() {
  const b = useBoard();
  const [name, setName] = useState("");
  const active = b.members.filter((m) => !m.removed);
  return (
    <Popover className="btn btn-line btn-sm you-btn" label="Who are you on this board?" button={b.myMember ? <><span className="avatar">{initials(b.myMember.name)}</span><span className="you-name">{b.myMember.name}</span></> : <><Icon name="user" size={16} /> Your name</>}>
      {(close) => (
        <div className="menu-form picker">
          <p className="menu-title">Who are you?</p>
          <p className="muted small">Your name signs your comments. It is saved for this board on this device.</p>
          {active.length > 0 && (
            <ul className="pick-list">
              {active.map((m) => (
                <li key={m.id}>
                  <label className="check pick-check">
                    <input type="radio" name="me" checked={b.meId === m.id} onChange={() => { void b.actions.setMe(m.id); close(); }} />
                    <span className="avatar">{initials(m.name)}</span> {m.name}
                  </label>
                </li>
              ))}
            </ul>
          )}
          {!b.board?.archived && (
            <form className="row" style={{ gap: 6 }} onSubmit={async (e) => {
              e.preventDefault();
              const n = name.trim().slice(0, 40);
              if (!n) return;
              const id = await b.actions.addMember(n);
              if (id) { await b.actions.setMe(id); setName(""); close(); }
            }}>
              <input className="input input-sm grow" value={name} maxLength={40} placeholder={active.length ? "Not listed? Add your name" : "Your name"} aria-label="Your name" onChange={(e) => setName(e.target.value)} />
              <button className="btn btn-sm" type="submit">Save</button>
            </form>
          )}
        </div>
      )}
    </Popover>
  );
}

const DUE_OPTIONS: [DueFilter, string][] = [["overdue", "Overdue"], ["today", "Due today"], ["week", "Due in the next 7 days"], ["none", "No due date"], ["done", "Marked done"]];

function Toolbar({ view, setView, filter, setFilter }: { view: View; setView: (v: View) => void; filter: Filter; setFilter: (f: Filter) => void }) {
  const b = useBoard();
  const n = filterCount(filter) - (filter.q.trim() ? 1 : 0);
  const toggle = <K extends "labels" | "people" | "due">(k: K, v: Filter[K][number]) =>
    setFilter({ ...filter, [k]: (filter[k] as string[]).includes(v as string) ? (filter[k] as string[]).filter((x) => x !== v) : [...filter[k], v] });
  const active = b.members.filter((m) => !m.removed);
  const shown = b.cards.filter((c) => b.openLists.some((l) => l.id === c.listId));
  return (
    <div className="toolbar">
      <div className="segmented views" role="tablist" aria-label="View">
        {([["board", "Board", "board"], ["list", "List", "list"], ["calendar", "Calendar", "calendar"]] as const).map(([v, label, icon]) => (
          <button key={v} type="button" role="tab" aria-selected={view === v} onClick={() => setView(v)}><Icon name={icon} size={16} /> {label}</button>
        ))}
      </div>
      <label className="search">
        <Icon name="search" size={16} />
        <input type="search" value={filter.q} placeholder="Search cards" aria-label="Search cards" onChange={(e) => setFilter({ ...filter, q: e.target.value })} />
      </label>
      <Popover className={`btn btn-line btn-sm${n ? " is-on" : ""}`} label="Filter cards" button={<><Icon name="filter" size={16} /> Filter{n ? ` · ${n}` : ""}</>}>
        {() => (
          <div className="menu-form filter-panel">
            <div className="spread"><p className="menu-title">Filter cards</p>{n > 0 && <button type="button" className="link-btn" onClick={() => setFilter({ ...NO_FILTER, q: filter.q })}>Clear</button>}</div>
            <fieldset>
              <legend>Labels</legend>
              {b.labels.length ? b.labels.map((l) => (
                <label key={l.id} className="check"><input type="checkbox" checked={filter.labels.includes(l.id)} onChange={() => toggle("labels", l.id)} />
                  <span className="lbl" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span></label>
              )) : <p className="muted small">No labels yet.</p>}
            </fieldset>
            <fieldset>
              <legend>Assigned to</legend>
              {active.map((m) => (
                <label key={m.id} className="check"><input type="checkbox" checked={filter.people.includes(m.id)} onChange={() => toggle("people", m.id)} /> {m.name}{m.id === b.meId ? " (you)" : ""}</label>
              ))}
              <label className="check"><input type="checkbox" checked={filter.people.includes(NOBODY)} onChange={() => toggle("people", NOBODY)} /> No one</label>
            </fieldset>
            <fieldset>
              <legend>Due date</legend>
              {DUE_OPTIONS.map(([v, label]) => (
                <label key={v} className="check"><input type="checkbox" checked={filter.due.includes(v)} onChange={() => toggle("due", v)} /> {label}</label>
              ))}
            </fieldset>
          </div>
        )}
      </Popover>
      {filterCount(filter) > 0 && (
        <p className="filter-note">Filtering. <button type="button" className="link-btn" onClick={() => setFilter(NO_FILTER)}>Show all {shown.length} cards</button></p>
      )}
    </div>
  );
}

Change history

Point OpenApps links to openapps.free
9b0d0f7b9a · 17 hours ago
Bind agent address in policy
25f84633c0 · 19 hours ago
Use OpenApps service URLs and add contribution strip
07519eb348 · 19 hours ago
Bounded source sync
3296872001 · 1 day ago
Bounded source sync
ac8d2f5d18 · 1 day ago
Bounded source sync
2a5b08a001 · 1 day ago
Bounded source sync
d2242ddf34 · 1 day ago