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/Kanban.tsx
// The board view: lists side by side, cards in them. Cards and lists move by dragging with a mouse,
// by press-and-hold then drag on a touch screen, and with the keyboard (Space picks a card up, the
// arrow keys move it, Space drops it, Escape puts it back).
import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as RKeyboardEvent, type PointerEvent as RPointerEvent } from "react";
import { useBoard } from "./lib/board";
import { matches, filterCount, type Filter } from "./lib/filter";
import { Icon, Popover, say } from "./ui";
import { CardTile } from "./CardTile";
import type { Card } from "../functions/lib/tasks";

type Over = { listId: string; index: number } | { index: number };
interface Drag { kind: "card" | "list"; id: string; w: number; h: number; dx: number; dy: number; over: Over }
interface Pending { kind: "card" | "list"; id: string; x0: number; y0: number; touch: boolean; timer?: number; el: HTMLElement }
interface KbMove { id: string; listId: string; index: number }

const EDGE = 64;
const gap = (n: number) => ({ "--gap": `${n}px` }) as CSSProperties;

export function Kanban({ filter, onOpen }: { filter: Filter; onOpen: (cardId: string) => void }) {
  const b = useBoard();
  const readOnly = Boolean(b.board?.archived);
  const [drag, setDrag] = useState<Drag | null>(null);
  const [kb, setKb] = useState<KbMove | null>(null);
  const scroller = useRef<HTMLDivElement>(null);
  const ghost = useRef<HTMLDivElement>(null);
  const pointer = useRef({ x: 0, y: 0 });
  const pending = useRef<Pending | null>(null);
  const dragRef = useRef<Drag | null>(null);
  dragRef.current = drag;
  const suppressClick = useRef(false);
  const filtering = filterCount(filter) > 0;

  const visibleIn = (listId: string, without?: string) => b.cards.filter((c) => c.listId === listId && c.id !== without && (!filtering || matches(c, filter, b.labels)));
  const allIn = (listId: string, without?: string) => b.cards.filter((c) => c.listId === listId && c.id !== without);
  const listTitle = (id: string) => b.lists.find((l) => l.id === id)?.title ?? "";

  /** Turns a place among the visible cards into a place among all cards of the list (filters hide some). */
  function fullIndex(listId: string, cardId: string, visibleIndex: number) {
    const visible = visibleIn(listId, cardId);
    const all = allIn(listId, cardId);
    if (visibleIndex < visible.length) return all.indexOf(visible[visibleIndex]);
    return visible.length ? all.indexOf(visible[visible.length - 1]) + 1 : all.length;
  }

  function locate(x: number, y: number, kind: "card" | "list"): Over | null {
    if (kind === "list") {
      const cols = [...(scroller.current?.querySelectorAll<HTMLElement>("[data-col]:not(.is-dragging)") ?? [])];
      let index = cols.length;
      for (let i = 0; i < cols.length; i++) {
        const r = cols[i].getBoundingClientRect();
        if (x < r.left + r.width / 2) { index = i; break; }
      }
      return { index };
    }
    const col = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest<HTMLElement>("[data-col]");
    if (!col) return null;
    const tiles = [...col.querySelectorAll<HTMLElement>("[data-card]:not(.is-dragging)")];
    let index = tiles.length;
    for (let i = 0; i < tiles.length; i++) {
      const r = tiles[i].getBoundingClientRect();
      if (y < r.top + r.height / 2) { index = i; break; }
    }
    return { listId: col.dataset.col!, index };
  }

  const sameOver = (a: Over, b2: Over) => a.index === b2.index && ("listId" in a ? "listId" in b2 && a.listId === b2.listId : !("listId" in b2));

  function moveGhost() {
    const d = dragRef.current;
    if (d && ghost.current) ghost.current.style.transform = `translate(${pointer.current.x - d.dx}px, ${pointer.current.y - d.dy}px) rotate(${d.kind === "card" ? 2 : 1}deg)`;
  }

  function track(x: number, y: number) {
    pointer.current = { x, y };
    moveGhost();
    const d = dragRef.current;
    if (!d) return;
    const over = locate(x, y, d.kind);
    if (over && !sameOver(over, d.over)) setDrag({ ...d, over });
  }

  function start(p: Pending) {
    const r = p.el.getBoundingClientRect();
    let over: Over;
    if (p.kind === "card") {
      const card = b.cards.find((c) => c.id === p.id)!;
      over = { listId: card.listId, index: Math.max(0, visibleIn(card.listId).findIndex((c) => c.id === p.id)) };
    } else {
      over = { index: Math.max(0, b.openLists.findIndex((l) => l.id === p.id)) };
    }
    const d = { kind: p.kind, id: p.id, w: r.width, h: Math.min(r.height, p.kind === "list" ? 220 : r.height), dx: p.x0 - r.left, dy: p.y0 - r.top, over };
    dragRef.current = d;
    setDrag(d);
    document.body.classList.add("dragging");
    if (p.touch) navigator.vibrate?.(8);
  }

  function finish(commit: boolean) {
    const d = dragRef.current;
    pending.current = null;
    dragRef.current = null;
    setDrag(null);
    document.body.classList.remove("dragging");
    if (!d) return;
    suppressClick.current = true;
    window.setTimeout(() => { suppressClick.current = false; }, 50);
    if (!commit) return;
    if (d.kind === "card" && "listId" in d.over) {
      const card = b.cards.find((c) => c.id === d.id);
      if (!card) return;
      const idx = fullIndex(d.over.listId, d.id, d.over.index);
      const current = allIn(card.listId).findIndex((c) => c.id === d.id);
      if (d.over.listId === card.listId && idx === current) return;
      void b.actions.moveCard(d.id, d.over.listId, idx);
      say(`Moved ${card.title} to ${listTitle(d.over.listId)}, position ${idx + 1}.`);
    } else if (d.kind === "list") {
      const current = b.openLists.findIndex((l) => l.id === d.id);
      if (d.over.index !== current) void b.actions.moveList(d.id, d.over.index);
    }
  }

  // Window listeners for a press that may become a drag, and for the drag itself.
  useEffect(() => {
    const onMove = (e: PointerEvent) => {
      const p = pending.current;
      if (dragRef.current) { track(e.clientX, e.clientY); return; }
      if (!p) return;
      const dist = Math.hypot(e.clientX - p.x0, e.clientY - p.y0);
      if (p.touch) {
        if (dist > 10) { window.clearTimeout(p.timer); pending.current = null; }
      } else if (dist > 5) {
        start(p);
        track(e.clientX, e.clientY);
      }
    };
    const onUp = () => {
      if (pending.current) window.clearTimeout(pending.current.timer);
      if (dragRef.current) finish(true); else pending.current = null;
    };
    const onCancel = () => {
      if (pending.current) window.clearTimeout(pending.current.timer);
      if (dragRef.current) finish(false); else pending.current = null;
    };
    // Once a touch drag has started, the page must not scroll under the finger.
    const onTouchMove = (e: TouchEvent) => { if (dragRef.current) e.preventDefault(); };
    const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && dragRef.current) { e.preventDefault(); finish(false); } };
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    window.addEventListener("pointercancel", onCancel);
    window.addEventListener("touchmove", onTouchMove, { passive: false });
    window.addEventListener("keydown", onKey);
    return () => {
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      window.removeEventListener("pointercancel", onCancel);
      window.removeEventListener("touchmove", onTouchMove);
      window.removeEventListener("keydown", onKey);
    };
  });

  // Scrolls the board sideways, and a list up or down, while dragging near an edge.
  useEffect(() => {
    if (!drag) return;
    let raf = 0;
    const tick = () => {
      const s = scroller.current;
      const { x, y } = pointer.current;
      if (s) {
        const r = s.getBoundingClientRect();
        const left = x - r.left;
        const right = r.right - x;
        let moved = false;
        if (left < EDGE) { s.scrollLeft -= Math.ceil((EDGE - left) / 4); moved = true; }
        else if (right < EDGE) { s.scrollLeft += Math.ceil((EDGE - right) / 4); moved = true; }
        if (dragRef.current?.kind === "card") {
          const cards = (document.elementFromPoint(x, y) as HTMLElement | null)?.closest("[data-col]")?.querySelector<HTMLElement>(".col-cards");
          if (cards) {
            const cr = cards.getBoundingClientRect();
            if (y < cr.top + 40) { cards.scrollTop -= Math.ceil((cr.top + 40 - y) / 4); moved = true; }
            else if (y > cr.bottom - 40) { cards.scrollTop += Math.ceil((y - (cr.bottom - 40)) / 4); moved = true; }
          }
        }
        if (moved) track(x, y);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [Boolean(drag)]); // eslint-disable-line react-hooks/exhaustive-deps

  useLayoutEffect(() => { moveGhost(); });

  // Keyboard moves keep focus on the card as it changes lists.
  useEffect(() => {
    if (!kb) return;
    document.querySelector<HTMLElement>(`[data-card="${kb.id}"]`)?.focus({ preventScroll: false });
    const onDown = (e: PointerEvent) => { if (!(e.target as HTMLElement).closest?.(`[data-card="${kb.id}"]`)) setKb(null); };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [kb]);

  const press = (kind: "card" | "list", id: string) => (e: RPointerEvent<HTMLElement>) => {
    if (readOnly || kb || e.button !== 0 || !e.isPrimary) return;
    if (kind === "list" && (e.target as HTMLElement).closest(".col-menu, input")) return;
    const touch = e.pointerType !== "mouse";
    const p: Pending = { kind, id, x0: e.clientX, y0: e.clientY, touch, el: e.currentTarget.closest<HTMLElement>(kind === "card" ? "[data-card]" : "[data-col]")! };
    if (touch) p.timer = window.setTimeout(() => { if (pending.current === p) start(p); }, 260);
    pending.current = p;
  };

  function onCardKey(e: RKeyboardEvent<HTMLElement>, card: Card) {
    if (readOnly) return;
    if (kb && kb.id === card.id) {
      const lists = b.openLists;
      const li = lists.findIndex((l) => l.id === kb.listId);
      const count = visibleIn(kb.listId, card.id).length;
      let next: KbMove | null = null;
      if (e.key === "ArrowUp") next = { ...kb, index: Math.max(0, kb.index - 1) };
      else if (e.key === "ArrowDown") next = { ...kb, index: Math.min(count, kb.index + 1) };
      else if (e.key === "ArrowLeft" && li > 0) next = { id: kb.id, listId: lists[li - 1].id, index: Math.min(kb.index, visibleIn(lists[li - 1].id, card.id).length) };
      else if (e.key === "ArrowRight" && li < lists.length - 1) next = { id: kb.id, listId: lists[li + 1].id, index: Math.min(kb.index, visibleIn(lists[li + 1].id, card.id).length) };
      else if (e.key === " " || e.key === "Enter") {
        e.preventDefault();
        const idx = fullIndex(kb.listId, card.id, kb.index);
        const current = allIn(card.listId).findIndex((c) => c.id === card.id);
        setKb(null);
        if (kb.listId !== card.listId || idx !== current) void b.actions.moveCard(card.id, kb.listId, idx);
        say(`Dropped ${card.title} in ${listTitle(kb.listId)}, position ${kb.index + 1}.`);
        return;
      } else if (e.key === "Escape") {
        e.preventDefault();
        e.stopPropagation();
        setKb(null);
        say(`Move cancelled. ${card.title} is back in ${listTitle(card.listId)}.`);
        return;
      } else if (e.key === "Tab") { setKb(null); return; }
      if (next || e.key.startsWith("Arrow")) e.preventDefault();
      if (next) {
        setKb(next);
        say(`${listTitle(next.listId)}, position ${next.index + 1} of ${visibleIn(next.listId, card.id).length + 1}.`);
      }
      return;
    }
    if (e.key === " ") {
      e.preventDefault();
      const index = visibleIn(card.listId).findIndex((c) => c.id === card.id);
      setKb({ id: card.id, listId: card.listId, index });
      say(`Picked up ${card.title}. Use the arrow keys to move it, Space to drop it, Escape to cancel.`);
    }
  }

  const dragged = drag ? (drag.kind === "card" ? b.cards.find((c) => c.id === drag.id) : null) : null;
  const draggedList = drag?.kind === "list" ? b.openLists.find((l) => l.id === drag.id) : null;
  const listPlaceholder = drag?.kind === "list" ? drag.over.index : -1;

  // The dragged card or list stays in the page, hidden, so touch events keep reaching it.
  const column = (list: (typeof b.openLists)[number], hidden: boolean) => {
    let shown = visibleIn(list.id, kb?.id);
    const total = allIn(list.id).length;
    const ph = drag?.kind === "card" && "listId" in drag.over && drag.over.listId === list.id ? drag.over.index : -1;
    const kbCard = kb && kb.listId === list.id ? b.cards.find((c) => c.id === kb.id) : null;
    if (kbCard) shown = [...shown.slice(0, kb!.index), kbCard, ...shown.slice(kb!.index)];
    const others = shown.filter((c) => c.id !== dragged?.id).length;
    let j = 0;
    return (
      <section className={`col${hidden ? " is-dragging" : ""}`} data-col={list.id} aria-label={list.title}>
        <header className="col-head" onPointerDown={press("list", list.id)} onClickCapture={(e) => { if (suppressClick.current) { e.preventDefault(); e.stopPropagation(); } }}>
          <ListTitle id={list.id} title={list.title} readOnly={readOnly} />
          <span className="col-count" aria-label={`${total} cards`}>{filtering ? `${visibleIn(list.id).length}/${total}` : total}</span>
          {!readOnly && <ListMenu listId={list.id} />}
        </header>
        <div className="col-cards">
          {shown.map((c) => {
            const isDragged = c.id === dragged?.id;
            const place = isDragged ? -1 : j++;
            return (
              <div key={c.id} className="tile-slot">
                {!isDragged && ph === place && <div className="tile-ph" style={{ height: drag!.h }} />}
                <CardTile
                  card={c}
                  lifted={kb?.id === c.id}
                  dragging={isDragged}
                  onPointerDown={press("card", c.id)}
                  onClick={() => { if (!suppressClick.current && !kb) onOpen(c.id); }}
                  onKeyDown={(e) => onCardKey(e, c)}
                />
              </div>
            );
          })}
          {ph >= others && <div className="tile-ph" style={{ height: drag!.h }} />}
          {!others && ph < 0 && <p className="col-empty">{filtering && total ? "No cards match." : "No cards yet."}</p>}
        </div>
        {!readOnly && <AddCard listId={list.id} />}
      </section>
    );
  };

  let li = 0;
  const visibleLists = b.openLists.filter((l) => l.id !== draggedList?.id).length;
  return (
    <>
      <div className="kanban" ref={scroller} aria-describedby="dnd-help">
        {b.openLists.map((l) => {
          const hidden = l.id === draggedList?.id;
          const place = hidden ? -1 : li++;
          return (
            <div className={`col-slot${hidden ? " is-dragging" : ""}`} key={l.id}>
              {!hidden && listPlaceholder === place && <div className="col-ph" style={{ width: drag!.w }} />}
              {column(l, hidden)}
            </div>
          );
        })}
        {listPlaceholder >= visibleLists && <div className="col-ph" style={{ width: drag!.w }} />}
        {!readOnly && <AddList first={!b.openLists.length} />}
        <div className="kanban-end" aria-hidden="true" />
      </div>
      <p id="dnd-help" className="sr-only">Press Enter on a card to open it. Press Space to pick it up, the arrow keys to move it, and Space again to drop it.</p>
      {drag && (
        <div className="drag-ghost" ref={ghost} style={{ width: drag.w }} aria-hidden="true">
          {dragged && <CardTile card={dragged} />}
          {draggedList && (
            <div className="col col-ghost">
              <header className="col-head"><span className="col-title">{draggedList.title}</span><span className="col-count">{allIn(draggedList.id).length}</span></header>
            </div>
          )}
        </div>
      )}
    </>
  );
}

function ListTitle({ id, title, readOnly }: { id: string; title: string; readOnly: boolean }) {
  const b = useBoard();
  const [editing, setEditing] = useState(false);
  const [value, setValue] = useState(title);
  useEffect(() => { if (!editing) setValue(title); }, [title, editing]);
  if (!editing || readOnly) {
    return <h2 className="col-title-wrap"><button type="button" className="col-title" onClick={() => !readOnly && setEditing(true)} disabled={readOnly} title={readOnly ? undefined : "Rename list"}>{title}</button></h2>;
  }
  const save = () => {
    const t = value.trim();
    setEditing(false);
    if (t && t !== title) void b.actions.updateList(id, { title: t.slice(0, 120) });
  };
  return (
    <input className="input col-title-input" value={value} autoFocus maxLength={120} aria-label="List name" onChange={(e) => setValue(e.target.value)} onBlur={save}
      onKeyDown={(e) => { if (e.key === "Enter") save(); if (e.key === "Escape") { setValue(title); setEditing(false); } }} />
  );
}

function ListMenu({ listId }: { listId: string }) {
  const b = useBoard();
  const i = b.openLists.findIndex((l) => l.id === listId);
  return (
    <Popover className="icon-btn col-menu" label="List actions" button={<Icon name="more" />}>
      {(close) => (
        <div className="menu">
          <button type="button" disabled={i <= 0} onClick={() => { void b.actions.moveList(listId, i - 1); close(); }}><Icon name="left" /> Move list left</button>
          <button type="button" disabled={i >= b.openLists.length - 1} onClick={() => { void b.actions.moveList(listId, i + 1); close(); }}><Icon name="right" /> Move list right</button>
          <button type="button" onClick={() => { void b.actions.updateList(listId, { archived: true }); close(); }}><Icon name="archive" /> Archive list</button>
        </div>
      )}
    </Popover>
  );
}

function AddCard({ listId }: { listId: string }) {
  const b = useBoard();
  const [open, setOpen] = useState(false);
  const [value, setValue] = useState("");
  const ref = useRef<HTMLTextAreaElement>(null);
  const submit = async () => {
    const t = value.trim();
    if (!t) { setOpen(false); return; }
    setValue("");
    await b.actions.addCard(listId, t.slice(0, 500));
    ref.current?.focus();
    ref.current?.closest(".col")?.querySelector(".col-cards")?.scrollTo({ top: 1e6 });
  };
  if (!open) return <button type="button" className="add-card" onClick={() => setOpen(true)}><Icon name="plus" size={16} /> Add a card</button>;
  return (
    <div className="composer">
      <textarea ref={ref} className="textarea composer-input" rows={2} value={value} autoFocus maxLength={500} placeholder="What needs doing?" aria-label="Card title"
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); void submit(); }
          if (e.key === "Escape") { setOpen(false); setValue(""); }
        }} />
      <div className="row" style={gap(8)}>
        <button type="button" className="btn btn-sm" onClick={() => void submit()}>Add card</button>
        <button type="button" className="btn btn-sm btn-quiet" onClick={() => { setOpen(false); setValue(""); }}>Cancel</button>
      </div>
    </div>
  );
}

function AddList({ first }: { first: boolean }) {
  const b = useBoard();
  const [open, setOpen] = useState(first);
  const [value, setValue] = useState("");
  const submit = async () => {
    const t = value.trim();
    if (!t) return;
    setValue("");
    await b.actions.addList(t.slice(0, 120));
  };
  if (!open) return <button type="button" className="add-list" onClick={() => setOpen(true)}><Icon name="plus" size={16} /> Add a list</button>;
  return (
    <form className="add-list add-list-open" onSubmit={(e) => { e.preventDefault(); void submit(); }}>
      <input className="input" value={value} autoFocus={!first} maxLength={120} placeholder="List name, like Waiting" aria-label="List name" onChange={(e) => setValue(e.target.value)}
        onKeyDown={(e) => { if (e.key === "Escape" && !first) setOpen(false); }} />
      <div className="row" style={gap(8)}>
        <button type="submit" className="btn btn-sm">Add list</button>
        {!first && <button type="button" className="btn btn-sm btn-quiet" onClick={() => setOpen(false)}>Cancel</button>}
      </div>
    </form>
  );
}

Change history

Point OpenApps links to openapps.free
9b0d0f7b9a · 18 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