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/CardModal.tsx
// A card's details: title, list, labels, people, due date, cover, description (Markdown), checklist and
// comments. Every change saves right away and shows up for everyone on the board.
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { useQuery } from "@bounded-sh/client";
import { useBoard } from "./lib/board";
import { renderMarkdown } from "./lib/markdown";
import { Modal, Popover, Icon, copy } from "./ui";
import { DueBadge, initials } from "./CardTile";
import { SITE_URL } from "../functions/lib/config";
import { COLORS, COLOR_IDS, LIMITS, parseIds, parseChecklist, randomId, type Card, type Comment } from "../functions/lib/tasks";

type Row<T> = T & { id: string };
const gap = (n: number) => ({ "--gap": `${n}px` }) as CSSProperties;

export function CardModal({ cardId, onClose }: { cardId: string; onClose: () => void }) {
  const b = useBoard();
  const { data: stored } = useQuery(`${b.base}/cards/${cardId}`) as { data: Row<Card> | null | undefined };
  const live = b.cards.find((c) => c.id === cardId);
  const card = live || (stored ? { ...stored, id: cardId } : stored);
  const readOnly = Boolean(b.board?.archived);

  if (card === undefined) return <Modal title="Loading card" onClose={onClose}><div className="loading"><span className="spinner" /> Opening the card...</div></Modal>;
  if (!card) return (
    <Modal title="Card not found" onClose={onClose}>
      <p>This card was deleted, or the link is not complete.</p>
    </Modal>
  );
  return <CardDetails card={card} readOnly={readOnly} onClose={onClose} />;
}

function CardDetails({ card, readOnly, onClose }: { card: Row<Card>; readOnly: boolean; onClose: () => void }) {
  const b = useBoard();
  const list = b.lists.find((l) => l.id === card.listId);
  const update = (fields: Partial<Card>) => b.actions.updateCard(card.id, fields);
  const url = `${SITE_URL}/b/${b.boardId}/c/${card.id}`;
  const locked = readOnly || card.archived;

  return (
    <Modal wide onClose={onClose} label={card.title} title={<TitleEditor card={card} disabled={locked} onSave={(title) => update({ title })} />}>
      {card.archived && <p className="notice" style={{ marginBottom: 16 }}>This card is archived. Restore it to edit it or put it back on the board.</p>}
      <div className="card-detail">
        <div className="card-main">
          <p className="muted small card-where">
            In list <strong>{list?.title ?? "a removed list"}</strong>{list?.archived ? " (archived)" : ""}
            {!locked && <MoveCard card={card} />}
          </p>

          <div className="props">
            <Prop label="Labels">
              <span className="prop-chips">
                {b.cardLabels(card).map((l) => <span key={l.id} className="lbl lbl-lg" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span>)}
                {!locked && <LabelPicker card={card} />}
              </span>
            </Prop>
            <Prop label="Assigned to">
              <span className="prop-chips">
                {b.cardPeople(card).map((p) => <span key={p.id} className="person"><span className="avatar">{initials(p.name)}</span>{p.name}</span>)}
                {!locked && <PeoplePicker card={card} />}
              </span>
            </Prop>
            <Prop label="Due">
              <DueEditor card={card} disabled={locked} onChange={update} />
            </Prop>
            {!locked && (
              <Prop label="Cover">
                <span className="swatches" role="radiogroup" aria-label="Cover color">
                  <button type="button" role="radio" aria-checked={!card.cover} className="swatch swatch-none" onClick={() => update({ cover: "" })} aria-label="No cover" />
                  {COLOR_IDS.map((c) => <button key={c} type="button" role="radio" aria-checked={card.cover === c} className="swatch" style={{ background: COLORS[c].solid }} onClick={() => update({ cover: c })} aria-label={COLORS[c].name} />)}
                </span>
              </Prop>
            )}
          </div>

          <Description card={card} disabled={locked} onSave={(description) => update({ description })} />
          <Checklist card={card} disabled={locked} onSave={(checklist) => update({ checklist })} />
          <Comments card={card} disabled={readOnly} />
        </div>
      </div>
      <div className="card-actions">
        <button type="button" className="btn btn-line btn-sm" onClick={() => copy(url, "Card link copied.")}><Icon name="link" size={16} /> Copy link</button>
        {!readOnly && (card.archived ? (
          <>
            <button type="button" className="btn btn-line btn-sm" onClick={() => update({ archived: false })}>Restore card</button>
            <button type="button" className="btn btn-line btn-sm btn-danger" onClick={() => { if (confirm("Delete this card and its comments for everyone? This cannot be undone.")) { void b.actions.deleteCard(card.id); onClose(); } }}><Icon name="trash" size={16} /> Delete</button>
          </>
        ) : (
          <button type="button" className="btn btn-line btn-sm" onClick={() => { void update({ archived: true }); onClose(); }}><Icon name="archive" size={16} /> Archive</button>
        ))}
      </div>
    </Modal>
  );
}

function Prop({ label, children }: { label: string; children: ReactNode }) {
  return <div className="prop"><span className="prop-label">{label}</span><div className="prop-value">{children}</div></div>;
}

function TitleEditor({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (t: string) => void }) {
  const [value, setValue] = useState(card.title);
  const focused = useRef(false);
  useEffect(() => { if (!focused.current) setValue(card.title); }, [card.title]);
  const ref = useRef<HTMLTextAreaElement>(null);
  useEffect(() => {
    const el = ref.current;
    if (el) { el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }
  }, [value]);
  const save = () => {
    focused.current = false;
    const t = value.trim().slice(0, LIMITS.cardTitle);
    if (!t) setValue(card.title);
    else if (t !== card.title) onSave(t);
  };
  return (
    <textarea ref={ref} className="title-edit" rows={1} value={value} readOnly={disabled} aria-label="Card title" maxLength={LIMITS.cardTitle}
      onFocus={() => { focused.current = true; }}
      onChange={(e) => setValue(e.target.value.replace(/\n/g, " "))}
      onBlur={save}
      onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLTextAreaElement).blur(); } }} />
  );
}

function MoveCard({ card }: { card: Card }) {
  const b = useBoard();
  const [listId, setListId] = useState(card.listId);
  const others = b.cards.filter((c) => c.listId === listId && c.id !== card.id);
  const current = b.cards.filter((c) => c.listId === card.listId && c.id !== card.id).length;
  const here = b.cards.filter((c) => c.listId === card.listId).findIndex((c) => c.id === card.id);
  const [index, setIndex] = useState(listId === card.listId ? Math.max(0, here) : others.length);
  return (
    <Popover className="btn btn-quiet btn-sm move-btn" label="Move card" button="Move" align="start">
      {(close) => (
        <form className="menu-form" onSubmit={(e) => { e.preventDefault(); void b.actions.moveCard(card.id, listId, index); close(); }}>
          <p className="menu-title">Move card</p>
          <label className="field"><span>List</span>
            <select className="select" value={listId} onChange={(e) => {
              setListId(e.target.value);
              setIndex(b.cards.filter((c) => c.listId === e.target.value && c.id !== card.id).length);
            }}>
              {b.openLists.map((l) => <option key={l.id} value={l.id}>{l.title}</option>)}
            </select>
          </label>
          <label className="field"><span>Position</span>
            <select className="select" value={index} onChange={(e) => setIndex(Number(e.target.value))}>
              {Array.from({ length: (listId === card.listId ? current : others.length) + 1 }, (_, i) => <option key={i} value={i}>{i + 1}</option>)}
            </select>
          </label>
          <button className="btn btn-sm" type="submit">Move</button>
        </form>
      )}
    </Popover>
  );
}

function LabelPicker({ card }: { card: Card }) {
  const b = useBoard();
  const ids = parseIds(card.labels);
  const [name, setName] = useState("");
  const [color, setColor] = useState(COLOR_IDS[b.labels.length % COLOR_IDS.length]);
  const [editing, setEditing] = useState<string | null>(null);
  const toggle = (id: string) => b.actions.updateCard(card.id, { labels: JSON.stringify(ids.includes(id) ? ids.filter((x) => x !== id) : [...ids, id]) });
  return (
    <Popover className="chip-btn" label="Labels" button={<><Icon name="tag" size={15} /> {ids.length ? "Edit" : "Add"}</>} align="start">
      {() => (
        <div className="menu-form picker">
          <p className="menu-title">Labels</p>
          {b.labels.length > 0 && (
            <ul className="pick-list">
              {b.labels.map((l) => (
                <li key={l.id}>
                  {editing === l.id ? (
                    <LabelEdit id={l.id} name={l.name} color={l.color} done={() => setEditing(null)} />
                  ) : (
                    <>
                      <label className="check pick-check">
                        <input type="checkbox" checked={ids.includes(l.id)} onChange={() => toggle(l.id)} />
                        <span className="lbl lbl-lg" style={{ background: COLORS[l.color]?.bg, color: COLORS[l.color]?.fg }}>{l.name || COLORS[l.color]?.name}</span>
                      </label>
                      <button type="button" className="icon-btn icon-btn-sm" aria-label={`Edit label ${l.name || COLORS[l.color]?.name}`} onClick={() => setEditing(l.id)}><Icon name="edit" size={15} /></button>
                    </>
                  )}
                </li>
              ))}
            </ul>
          )}
          <form className="stack" style={gap(8)} onSubmit={async (e) => {
            e.preventDefault();
            const n = name.trim().slice(0, LIMITS.labelName);
            const id = await b.actions.addLabel(n, color);
            if (id) { toggle(id); setName(""); setColor(COLOR_IDS[(b.labels.length + 1) % COLOR_IDS.length]); }
          }}>
            <input className="input input-sm" value={name} maxLength={LIMITS.labelName} placeholder="New label name" aria-label="New label name" onChange={(e) => setName(e.target.value)} />
            <ColorPick value={color} onChange={setColor} />
            <button className="btn btn-sm" type="submit">Create label</button>
          </form>
        </div>
      )}
    </Popover>
  );
}

export function LabelEdit({ id, name, color, done }: { id: string; name: string; color: string; done: () => void }) {
  const b = useBoard();
  const [n, setN] = useState(name);
  const [c, setC] = useState(color);
  return (
    <div className="stack label-edit" style={gap(8)}>
      <input className="input input-sm" value={n} maxLength={LIMITS.labelName} aria-label="Label name" onChange={(e) => setN(e.target.value)} autoFocus />
      <ColorPick value={c} onChange={setC} />
      <div className="row" style={gap(6)}>
        <button type="button" className="btn btn-sm" onClick={() => { void b.actions.updateLabel(id, { name: n.trim(), color: c }); done(); }}>Save</button>
        <button type="button" className="btn btn-sm btn-quiet" onClick={done}>Cancel</button>
        <button type="button" className="btn btn-sm btn-quiet btn-danger" onClick={() => { if (confirm("Delete this label from the board? Cards lose it.")) { void b.actions.deleteLabel(id); done(); } }}>Delete</button>
      </div>
    </div>
  );
}

export function ColorPick({ value, onChange }: { value: string; onChange: (c: string) => void }) {
  return (
    <span className="swatches" role="radiogroup" aria-label="Color">
      {COLOR_IDS.map((c) => <button key={c} type="button" role="radio" aria-checked={value === c} className="swatch" style={{ background: COLORS[c].solid }} onClick={() => onChange(c)} aria-label={COLORS[c].name} />)}
    </span>
  );
}

function PeoplePicker({ card }: { card: Card }) {
  const b = useBoard();
  const ids = parseIds(card.assignees);
  const [name, setName] = useState("");
  const set = (next: string[]) => b.actions.updateCard(card.id, { assignees: JSON.stringify(next) });
  const active = b.members.filter((m) => !m.removed);
  return (
    <Popover className="chip-btn" label="Assign people" button={<><Icon name="user" size={15} /> {ids.length ? "Edit" : "Assign"}</>} align="start">
      {() => (
        <div className="menu-form picker">
          <p className="menu-title">Assign people</p>
          {active.length > 0 ? (
            <ul className="pick-list">
              {active.map((m) => (
                <li key={m.id}>
                  <label className="check pick-check">
                    <input type="checkbox" checked={ids.includes(m.id)} onChange={() => set(ids.includes(m.id) ? ids.filter((x) => x !== m.id) : [...ids, m.id])} />
                    <span className="avatar">{initials(m.name)}</span> {m.name}{m.id === b.meId ? <span className="muted"> (you)</span> : null}
                  </label>
                </li>
              ))}
            </ul>
          ) : <p className="muted small">No people yet. Add the names of the people on this board.</p>}
          <form className="row" style={gap(6)} onSubmit={async (e) => {
            e.preventDefault();
            const n = name.trim().slice(0, LIMITS.memberName);
            if (!n) return;
            const id = await b.actions.addMember(n);
            if (id) { void set([...ids.filter((x) => x !== id), id]); setName(""); }
          }}>
            <input className="input input-sm grow" value={name} maxLength={LIMITS.memberName} placeholder="Add a name" aria-label="Add a person by name" onChange={(e) => setName(e.target.value)} />
            <button className="btn btn-sm" type="submit">Add</button>
          </form>
        </div>
      )}
    </Popover>
  );
}

function DueEditor({ card, disabled, onChange }: { card: Card; disabled: boolean; onChange: (f: Partial<Card>) => void }) {
  const day = card.due.slice(0, 10);
  const time = card.due.length === 16 ? card.due.slice(11) : "";
  if (disabled) return card.due ? <DueBadge card={card} /> : <span className="muted">None</span>;
  return (
    <span className="due-edit">
      <input type="date" className="input input-sm" value={day} aria-label="Due date" onChange={(e) => onChange({ due: e.target.value ? (time ? `${e.target.value}T${time}` : e.target.value) : "" })} />
      {day && <input type="time" className="input input-sm" value={time} aria-label="Due time (optional)" onChange={(e) => onChange({ due: e.target.value ? `${day}T${e.target.value}` : day })} />}
      {day && (
        <label className="check">
          <input type="checkbox" checked={card.done} onChange={(e) => onChange({ done: e.target.checked })} /> Done
        </label>
      )}
      {day && <button type="button" className="btn btn-quiet btn-sm" onClick={() => onChange({ due: "", done: false })}>Clear</button>}
      {day && <DueBadge card={card} />}
    </span>
  );
}

function Description({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (d: string) => void }) {
  const [editing, setEditing] = useState(false);
  const [value, setValue] = useState(card.description);
  const html = useMemo(() => (card.description ? renderMarkdown(card.description) : ""), [card.description]);
  useEffect(() => { if (!editing) setValue(card.description); }, [card.description, editing]);
  return (
    <section className="detail-section">
      <div className="spread"><h3 className="detail-h"><Icon name="text" size={17} /> Description</h3>
        {!disabled && !editing && card.description && <button type="button" className="btn btn-quiet btn-sm" onClick={() => setEditing(true)}>Edit</button>}</div>
      {editing ? (
        <div className="stack" style={gap(8)}>
          <textarea className="textarea desc-input" value={value} autoFocus maxLength={LIMITS.description} rows={8} aria-label="Description" onChange={(e) => setValue(e.target.value)}
            onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { onSave(value); setEditing(false); } }} />
          <div className="spread">
            <span className="hint">Markdown works: **bold**, lists, [links](https://example.com).</span>
            <span className="row" style={gap(6)}>
              <button type="button" className="btn btn-sm" onClick={() => { onSave(value); setEditing(false); }}>Save</button>
              <button type="button" className="btn btn-sm btn-quiet" onClick={() => { setValue(card.description); setEditing(false); }}>Cancel</button>
            </span>
          </div>
        </div>
      ) : card.description ? (
        <div className="md prose" dangerouslySetInnerHTML={{ __html: html }} />
      ) : disabled ? <p className="muted small">No description.</p> : (
        <button type="button" className="desc-empty" onClick={() => setEditing(true)}>Add a description: notes, links, steps...</button>
      )}
    </section>
  );
}

function Checklist({ card, disabled, onSave }: { card: Card; disabled: boolean; onSave: (c: string) => void }) {
  const items = parseChecklist(card.checklist);
  const [text, setText] = useState("");
  const [editing, setEditing] = useState<string | null>(null);
  const [hideDone, setHideDone] = useState(false);
  const save = (next: typeof items) => onSave(JSON.stringify(next));
  const done = items.filter((x) => x.done).length;
  const pct = items.length ? Math.round((done / items.length) * 100) : 0;
  const shown = hideDone ? items.filter((x) => !x.done) : items;
  return (
    <section className="detail-section">
      <div className="spread">
        <h3 className="detail-h"><Icon name="check" size={17} /> Checklist</h3>
        {done > 0 && <button type="button" className="btn btn-quiet btn-sm" onClick={() => setHideDone((h) => !h)}>{hideDone ? `Show checked (${done})` : "Hide checked"}</button>}
      </div>
      {items.length > 0 && (
        <div className="progress" aria-label={`${done} of ${items.length} done`}>
          <span className="progress-num">{pct}%</span>
          <span className="progress-bar"><span style={{ width: `${pct}%` }} /></span>
        </div>
      )}
      <ul className="checklist">
        {shown.map((it) => (
          <li key={it.id} className={it.done ? "is-done" : ""}>
            <input type="checkbox" checked={it.done} disabled={disabled} aria-label={it.text} onChange={(e) => save(items.map((x) => (x.id === it.id ? { ...x, done: e.target.checked } : x)))} />
            {editing === it.id ? (
              <input className="input input-sm grow" defaultValue={it.text} autoFocus maxLength={LIMITS.checkText} aria-label="Edit item"
                onBlur={(e) => { const t = e.target.value.trim(); setEditing(null); if (t && t !== it.text) save(items.map((x) => (x.id === it.id ? { ...x, text: t } : x))); }}
                onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { e.stopPropagation(); setEditing(null); } }} />
            ) : (
              <button type="button" className="check-text" disabled={disabled} onClick={() => setEditing(it.id)}>{it.text}</button>
            )}
            {!disabled && <button type="button" className="icon-btn icon-btn-sm" aria-label={`Delete ${it.text}`} onClick={() => save(items.filter((x) => x.id !== it.id))}><Icon name="close" size={14} /></button>}
          </li>
        ))}
      </ul>
      {!disabled && items.length < LIMITS.checkItems && (
        <form className="row check-add" style={gap(6)} onSubmit={(e) => {
          e.preventDefault();
          const t = text.trim().slice(0, LIMITS.checkText);
          if (!t) return;
          save([...items, { id: randomId(8), text: t, done: false }]);
          setText("");
        }}>
          <input className="input input-sm grow" value={text} maxLength={LIMITS.checkText} placeholder="Add an item" aria-label="Add a checklist item" onChange={(e) => setText(e.target.value)} />
          <button className="btn btn-sm btn-line" type="submit">Add</button>
        </form>
      )}
    </section>
  );
}

function timeAgo(sec: number): string {
  const s = Math.max(0, Date.now() / 1000 - sec);
  if (s < 60) return "just now";
  if (s < 3600) return `${Math.floor(s / 60)} min ago`;
  if (s < 86400) return `${Math.floor(s / 3600)} h ago`;
  return new Date(sec * 1000).toLocaleDateString(undefined, { month: "short", day: "numeric", year: s > 300 * 86400 ? "numeric" : undefined });
}

function Comments({ card, disabled }: { card: Card; disabled: boolean }) {
  const b = useBoard();
  const { data } = useQuery(`${b.base}/comments`, { limit: 1000, filter: { cardId: card.id } }) as { data: Row<Comment>[] | undefined };
  const comments = useMemo(() => [...(data || [])].sort((a, x) => a.createdAt - x.createdAt), [data]);
  const [text, setText] = useState("");
  const [name, setName] = useState("");
  const [editing, setEditing] = useState<string | null>(null);
  const [busy, setBusy] = useState(false);
  const submit = async () => {
    const t = text.trim().slice(0, LIMITS.comment);
    if (!t) return;
    let who = b.myMember?.name;
    if (!who) {
      const n = name.trim().slice(0, LIMITS.memberName);
      if (!n) return;
      const id = await b.actions.addMember(n);
      if (!id) return;
      await b.actions.setMe(id);
      who = n;
    }
    setBusy(true);
    if (await b.actions.addComment(card.id, t, who)) setText("");
    setBusy(false);
  };
  return (
    <section className="detail-section">
      <h3 className="detail-h"><Icon name="comment" size={17} /> Comments</h3>
      {data === undefined ? <p className="muted small">Loading comments...</p> : (
        <ul className="comments">
          {comments.map((c) => (
            <li key={c.id}>
              <span className="avatar">{initials(c.name)}</span>
              <div className="comment-body">
                <p className="comment-meta"><strong>{c.name}</strong> <span className="muted">{timeAgo(c.createdAt)}{c.editedAt ? ", edited" : ""}</span></p>
                {editing === c.id ? (
                  <CommentEdit text={c.text} onDone={(t) => { setEditing(null); if (t && t !== c.text) void b.actions.editComment(c.id, t); }} />
                ) : <p className="comment-text">{c.text}</p>}
                {c.by === b.uid && !disabled && editing !== c.id && (
                  <p className="comment-tools">
                    <button type="button" className="link-btn" onClick={() => setEditing(c.id)}>Edit</button>
                    <button type="button" className="link-btn" onClick={() => { if (confirm("Delete this comment?")) void b.actions.deleteComment(c.id); }}>Delete</button>
                  </p>
                )}
                {c.by !== b.uid && b.isOwner && !disabled && (
                  <p className="comment-tools"><button type="button" className="link-btn" onClick={() => { if (confirm("Delete this comment?")) void b.actions.deleteComment(c.id); }}>Delete</button></p>
                )}
              </div>
            </li>
          ))}
        </ul>
      )}
      {!disabled && (
        <form className="stack comment-form" style={gap(8)} onSubmit={(e) => { e.preventDefault(); void submit(); }}>
          {!b.myMember && <input className="input input-sm" value={name} maxLength={LIMITS.memberName} placeholder="Your name" aria-label="Your name" onChange={(e) => setName(e.target.value)} />}
          <textarea className="textarea comment-input" rows={2} value={text} maxLength={LIMITS.comment} placeholder="Write a comment" aria-label="Write a comment" onChange={(e) => setText(e.target.value)}
            onKeyDown={(e) => { if ((e.metaKey || e.ctrlKey) && e.key === "Enter") void submit(); }} />
          <div><button className="btn btn-sm" type="submit" disabled={busy || !text.trim() || (!b.myMember && !name.trim())}>Comment</button></div>
        </form>
      )}
    </section>
  );
}

function CommentEdit({ text, onDone }: { text: string; onDone: (t: string) => void }) {
  const [value, setValue] = useState(text);
  return (
    <div className="stack" style={gap(6)}>
      <textarea className="textarea comment-input" value={value} autoFocus maxLength={LIMITS.comment} aria-label="Edit comment" onChange={(e) => setValue(e.target.value)} />
      <span className="row" style={gap(6)}>
        <button type="button" className="btn btn-sm" onClick={() => onDone(value.trim())}>Save</button>
        <button type="button" className="btn btn-sm btn-quiet" onClick={() => onDone(text)}>Cancel</button>
      </span>
    </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