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/Home.tsx
// The landing hero's board maker (name, template, or a Trello or Tasks file to import) and "Your boards".
import { useEffect, useRef, useState, type FormEvent } from "react";
import { createBoard } from "./lib/board";
import { explain } from "./lib/bounded";
import { navigate } from "./lib/router";
import { myBoards, forgetBoard, type MyBoard } from "./lib/mine";
import { Icon } from "./ui";
import { TEMPLATES, templateById, readSeed, fromTrello, isTrello, localDay, SeedError, type BoardSeed } from "../functions/lib/tasks";

function startTemplate(): string {
  const t = new URLSearchParams(location.search).get("template") || "";
  return templateById(t) ? t : "kanban";
}

export function CreateBoard() {
  const [name, setName] = useState("");
  const [template, setTemplate] = useState(startTemplate);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [imported, setImported] = useState<{ seed: BoardSeed; from: string } | null>(null);
  const file = useRef<HTMLInputElement>(null);

  useEffect(() => {
    if (new URLSearchParams(location.search).get("template")) document.getElementById("start")?.scrollIntoView({ block: "center" });
  }, []);

  const go = async (seed: BoardSeed) => {
    setBusy(true);
    setError(null);
    try {
      const { boardId } = await createBoard(seed);
      try { localStorage.setItem(`tasks:new:${boardId}`, "1"); } catch { /* fine */ }
      navigate(`/b/${boardId}`);
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };

  const submit = (e: FormEvent) => {
    e.preventDefault();
    if (imported) {
      void go({ ...imported.seed, title: name.trim() || imported.seed.title });
      return;
    }
    const t = templateById(template) ?? TEMPLATES[0];
    const seed = t.seed(localDay(new Date()));
    void go({ ...seed, title: (name.trim() || seed.title || "My board").slice(0, 120) });
  };

  const onFile = async (f: File | undefined) => {
    if (!f) return;
    setError(null);
    try {
      if (f.size > 20 * 1024 * 1024) throw new SeedError("That file is over 20 MB. Export a single board from Trello.");
      const raw = JSON.parse(await f.text());
      const seed = isTrello(raw) ? fromTrello(raw) : readSeed(raw);
      setImported({ seed, from: isTrello(raw) ? "Trello" : "Tasks" });
      setName(seed.title);
    } catch (err) {
      setImported(null);
      setError(err instanceof SeedError ? err.message : err instanceof SyntaxError ? "That file is not JSON. In Trello, open the board menu, then Print, export and share, then Export as JSON." : explain(err));
    }
    if (file.current) file.current.value = "";
  };

  const counts = imported && {
    lists: imported.seed.lists.length,
    cards: imported.seed.lists.reduce((a, l) => a + l.cards.length, 0),
    comments: imported.seed.lists.reduce((a, l) => a + l.cards.reduce((x, c) => x + c.comments.length, 0), 0),
  };

  return (
    <form className="create card" onSubmit={submit} noValidate>
      <label className="field">
        <span>Board name</span>
        <input className="input" value={name} onChange={(e) => setName(e.target.value)} maxLength={120} placeholder="Team tasks, Launch plan, Home projects" autoComplete="off" />
      </label>
      {imported && counts ? (
        <div className="notice import-note">
          <strong>From {imported.from}:</strong> {counts.lists} lists, {counts.cards} cards{counts.comments ? `, ${counts.comments} comments` : ""}, {imported.seed.labels.length} labels.
          <button type="button" className="link-btn" onClick={() => { setImported(null); setName(""); }}>Use a template instead</button>
        </div>
      ) : (
        <fieldset className="templates">
          <legend className="label">Start from</legend>
          {TEMPLATES.map((t) => (
            <label key={t.id} className={`tpl${template === t.id ? " on" : ""}`}>
              <input type="radio" name="template" value={t.id} checked={template === t.id} onChange={() => setTemplate(t.id)} />
              <span className="tpl-name">{t.name}</span>
              <span className="tpl-sum">{t.summary}</span>
            </label>
          ))}
        </fieldset>
      )}
      {error && <p className="notice notice-error" role="alert">{error}</p>}
      <button className="btn btn-lg btn-block" type="submit" disabled={busy}>{busy ? (imported ? "Importing..." : "Creating...") : imported ? "Import board" : "Create board"}</button>
      <div className="create-foot">
        <span className="hint">Free. No sign-up. Anyone with the link can edit.</span>
        <button type="button" className="link-btn" onClick={() => file.current?.click()}><Icon name="upload" size={15} /> Import from Trello</button>
        <input ref={file} type="file" accept="application/json,.json" hidden onChange={(e) => void onFile(e.target.files?.[0])} />
      </div>
    </form>
  );
}

export function YourBoards() {
  const [list, setList] = useState<MyBoard[]>(myBoards);
  useEffect(() => {
    const onStorage = () => setList(myBoards());
    window.addEventListener("storage", onStorage);
    return () => window.removeEventListener("storage", onStorage);
  }, []);
  if (!list.length) return null;
  return (
    <section className="your-boards" aria-label="Your boards">
      <h2 className="label">Your boards</h2>
      <ul>
        {list.slice(0, 8).map((b) => (
          <li key={b.id}>
            <a href={`/b/${b.id}`} onClick={(e) => { e.preventDefault(); navigate(`/b/${b.id}`); }}>{b.title || "Untitled board"}</a>
            {b.key && <span className="chip chip-cobalt">Admin</span>}
            <button type="button" className="icon-btn icon-btn-sm" aria-label={`Remove ${b.title || "board"} from this list`} title="Remove from this list (the board stays)" onClick={() => { if (b.key && !confirm("This also forgets the admin link saved on this device. Copy it from Board settings first if you need it. Remove from the list?")) return; forgetBoard(b.id); setList(myBoards()); }}><Icon name="close" size={14} /></button>
          </li>
        ))}
      </ul>
    </section>
  );
}

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