polls-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 c06cea4889e9
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 334801de44cb0fe911d5a88b
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/Admin.tsx
// The host's page at /e/<id>/admin: add and order polls, open and close them, show results, moderate
// the Q&A, export CSV, and share the code, link and QR code.
import { useEffect, useMemo, useState } from "react";
import { set, serverTimestamp } from "@bounded-sh/client";
import { explain } from "./lib/bounded";
import { keyFor } from "./lib/mine";
import { useAll, useEvent, usePolls, usePollResults, readAll, showOnScreen, setResultsShown, setVoting, movePoll, deletePoll, clearVotes, pollPath } from "./lib/event";
import { Modal, toast, copy } from "./ui";
import { Qr } from "./Qr";
import { Loading } from "./Event";
import { ResultsView } from "./Results";
import { SITE_URL } from "../functions/lib/config";
import {
  POLL_TYPES, LIMITS, checkPoll, checkTitle, hasOptions, newPollDoc, summarize, resultsCsv, questionsCsv, byVotes, fileName, formatCode, randomId,
  type EventDoc, type Poll, type PollType, type QuestionDoc, type QuestionStatus, type VoteRow,
} from "../functions/lib/polls";

type Tab = "polls" | "qa" | "settings";

export const audienceUrl = (eventId: string) => `${SITE_URL}/e/${eventId}`;
export const SITE_HOST = SITE_URL.replace("https://", "");

function download(text: string, name: string) {
  const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
  const a = document.createElement("a");
  a.href = url;
  a.download = name;
  document.body.append(a);
  a.click();
  a.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}

export function Admin({ eventId }: { eventId: string }) {
  const event = useEvent(eventId);
  const polls = usePolls(eventId);
  const questions = useAll<QuestionDoc>(`events/${eventId}/questions`);
  const [tab, setTab] = useState<Tab>("polls");
  const [sharing, setSharing] = useState(false);
  const [isNew, setIsNew] = useState(() => { try { return localStorage.getItem(`polls:new:${eventId}`) === "1"; } catch { return false; } });
  useEffect(() => { if (event?.title) document.title = `${event.title} | Polls`; }, [event?.title]);

  if (event === undefined || polls === undefined) return <Loading text="Loading your event..." />;
  if (!event) return null;
  const pending = (questions || []).filter((q) => q.status === "pending").length;
  const dismissNew = () => { try { localStorage.removeItem(`polls:new:${eventId}`); } catch { /* ok */ } setIsNew(false); };
  const tabs: [Tab, string][] = [["polls", `Polls · ${polls.length}`], ["qa", pending ? `Q&A · ${pending} waiting` : "Q&A"], ["settings", "Settings"]];

  return (
    <div className="owner">
      <header className="owner-head">
        <div className="wrap owner-head-inner">
          <div className="owner-title-block">
            <p className="eyebrow">Your event</p>
            <h1 className="owner-title">{event.title}</h1>
            <p className="join-line">Join at <strong>{SITE_HOST}</strong> with code <button type="button" className="code-btn" onClick={() => setSharing(true)}>{formatCode(event.code)}</button></p>
          </div>
          <div className="row owner-actions">
            <a className="btn" href={`/e/${eventId}/present`} target="_blank" rel="noopener">
              Present
              <svg width="15" height="15" viewBox="0 0 20 20" aria-hidden="true"><path d="M3 4h14v9H3zM10 13v3M6.5 16.5h7" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
            </a>
            <button className="btn btn-line" type="button" onClick={() => setSharing(true)}>Share</button>
            <a className="btn btn-quiet" href={`/e/${eventId}`} target="_blank" rel="noopener">Audience view</a>
          </div>
        </div>
        <nav className="wrap owner-tabs" aria-label="Event">
          {tabs.map(([t, label]) => (
            <button key={t} type="button" aria-current={t === tab ? "page" : undefined} onClick={() => setTab(t)}>{label}</button>
          ))}
        </nav>
      </header>
      <div className="wrap owner-body">
        {isNew && <NewNotice eventId={eventId} onDismiss={dismissNew} />}
        {tab === "polls" && <AdminPolls eventId={eventId} event={event} polls={polls} />}
        {tab === "qa" && <AdminQA eventId={eventId} event={event} questions={questions} />}
        {tab === "settings" && <Settings eventId={eventId} event={event} />}
      </div>
      {sharing && <ShareModal eventId={eventId} event={event} onClose={() => setSharing(false)} />}
    </div>
  );
}

function adminUrl(eventId: string): string | null {
  const key = keyFor(eventId);
  return key ? `${SITE_URL}/e/${eventId}/admin#k=${key}` : null;
}

function NewNotice({ eventId, onDismiss }: { eventId: string; onDismiss: () => void }) {
  const url = adminUrl(eventId);
  return (
    <div className="notice notice-ok new-notice">
      <div>
        <strong>Your event is ready.</strong> Add polls, then press Present on the screen you share. Save your admin link: it is the only way to run this event from another device or browser.
      </div>
      <span className="row">
        {url && <button className="btn btn-sm" type="button" onClick={() => copy(url, "Admin link copied. Keep it private.")}>Copy admin link</button>}
        <button className="btn btn-sm btn-quiet" type="button" onClick={onDismiss}>Dismiss</button>
      </span>
    </div>
  );
}

function ShareModal({ eventId, event, onClose }: { eventId: string; event: EventDoc; onClose: () => void }) {
  const url = audienceUrl(eventId);
  return (
    <Modal title="Invite your audience" onClose={onClose}>
      <div className="share-box">
        <p className="muted">People go to <strong>{SITE_HOST}</strong> and type the code, or scan the QR code. No app, no account.</p>
        <p className="share-code">{formatCode(event.code)}</p>
        <Qr text={url} size={220} label="QR code that opens this event" />
        <div className="link-row">
          <code className="link-code">{url}</code>
          <button className="btn btn-sm" type="button" onClick={() => copy(url, "Audience link copied")}>Copy link</button>
        </div>
      </div>
    </Modal>
  );
}

const TYPE_HELP: Record<PollType, string> = {
  choice: "Pick one option, or several.",
  wordcloud: "Short answers that grow with every mention.",
  rating: "A score from 1 to 5, or up to 10.",
  open: "Free answers, shown as cards.",
  ranking: "Put the options in order.",
};

function AdminPolls({ eventId, event, polls }: { eventId: string; event: EventDoc; polls: Poll[] }) {
  const [editing, setEditing] = useState<{ poll?: Poll; type?: PollType } | null>(null);
  const [exporting, setExporting] = useState(false);

  const exportCsv = async () => {
    setExporting(true);
    try {
      const summaries = await Promise.all(polls.map(async (p) => summarize(p, await readAll<VoteRow>(`${pollPath(eventId, p.id)}/votes`))));
      download(resultsCsv(polls, summaries), fileName(event.title, "results"));
    } catch (err) {
      toast(explain(err));
    }
    setExporting(false);
  };

  return (
    <div className="admin-polls">
      {polls.length === 0 ? (
        <div className="empty-polls">
          <img className="art" src="/art/card-polls.webp" alt="" width="140" height="140" />
          <h2 className="h2">Add your first poll</h2>
          <p className="muted">Pick a kind. You can add as many as you like and reorder them later.</p>
          <TypePicker onPick={(type) => setEditing({ type })} />
        </div>
      ) : (
        <>
          <div className="spread polls-bar">
            <button className="btn" type="button" onClick={() => setEditing({})}>Add a poll</button>
            <button className="btn btn-line" type="button" onClick={exportCsv} disabled={exporting}>{exporting ? "Preparing..." : "Results (CSV)"}</button>
          </div>
          <ol className="poll-list">
            {polls.map((p, i) => (
              <AdminPollCard key={p.id} eventId={eventId} event={event} poll={p} index={i} polls={polls} onEdit={() => setEditing({ poll: p })} />
            ))}
          </ol>
        </>
      )}
      {editing && <PollEditor eventId={eventId} polls={polls} initial={editing.poll} type={editing.type} onClose={() => setEditing(null)} />}
    </div>
  );
}

function TypePicker({ onPick }: { onPick: (t: PollType) => void }) {
  return (
    <div className="type-grid">
      {(Object.keys(POLL_TYPES) as PollType[]).map((t) => (
        <button key={t} type="button" className="type-btn" onClick={() => onPick(t)}>
          <strong>{POLL_TYPES[t]}</strong>
          <span>{TYPE_HELP[t]}</span>
        </button>
      ))}
    </div>
  );
}

const STATUS_LABEL = { draft: "Draft", open: "Voting open", closed: "Voting closed" };

function AdminPollCard({ eventId, event, poll, index, polls, onEdit }: { eventId: string; event: EventDoc; poll: Poll; index: number; polls: Poll[]; onEdit: () => void }) {
  const { summary } = usePollResults(eventId, poll);
  const [busy, setBusy] = useState(false);
  const onScreen = event.current === poll.id;
  const run = async (job: () => Promise<unknown>, done?: string) => {
    setBusy(true);
    try { await job(); if (done) toast(done); } catch (err) { toast(explain(err)); }
    setBusy(false);
  };
  const n = summary?.n ?? 0;

  return (
    <li className={`card apoll${onScreen ? " on-screen" : ""}`}>
      <div className="apoll-top">
        <span className="q-num">{index + 1}</span>
        <span className="chip">{POLL_TYPES[poll.type]}</span>
        <span className={`chip status-${poll.status}`}>{STATUS_LABEL[poll.status]}</span>
        {onScreen && <span className="chip chip-cobalt">On screen</span>}
        {poll.status !== "draft" && <span className="apoll-count">{n.toLocaleString()} {n === 1 ? "answer" : "answers"}</span>}
      </div>
      <h3 className="apoll-q">{poll.question}</h3>
      <div className="apoll-actions">
        {poll.status === "open"
          ? <button className="btn btn-sm" type="button" disabled={busy} onClick={() => run(() => setVoting(eventId, poll, false), "Voting closed")}>Close voting</button>
          : <button className="btn btn-sm" type="button" disabled={busy} onClick={() => run(() => setVoting(eventId, poll, true), "Voting open")}>{poll.status === "draft" ? "Open voting" : "Reopen voting"}</button>}
        <button className="btn btn-sm btn-line" type="button" disabled={busy} aria-pressed={poll.showResults} onClick={() => run(() => setResultsShown(eventId, poll, !poll.showResults))}>
          {poll.showResults ? "Hide results" : "Show results"}
        </button>
        <button className="btn btn-sm btn-line" type="button" disabled={busy || onScreen} onClick={() => run(() => showOnScreen(eventId, poll))}>{onScreen ? "On screen" : "Show on screen"}</button>
      </div>
      {poll.status !== "draft" && summary && <div className="apoll-results"><ResultsView poll={poll} summary={summary} /></div>}
      {poll.status === "draft" && hasOptions(poll.type) && <p className="muted small">{poll.options.join(" · ")}</p>}
      <div className="apoll-tools">
        <button className="btn btn-sm btn-quiet" type="button" onClick={onEdit}>Edit</button>
        <button className="icon-btn" type="button" disabled={busy || index === 0} aria-label="Move up" title="Move up" onClick={() => run(() => movePoll(eventId, polls, index, -1))}>
          <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 15V5M5.5 9.5 10 5l4.5 4.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
        <button className="icon-btn" type="button" disabled={busy || index === polls.length - 1} aria-label="Move down" title="Move down" onClick={() => run(() => movePoll(eventId, polls, index, 1))}>
          <svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><path d="M10 5v10M5.5 10.5 10 15l4.5-4.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>
        </button>
        <span className="grow" />
        {n > 0 && (
          <button className="btn btn-sm btn-quiet" type="button" disabled={busy}
            onClick={() => { if (confirm(`Delete all ${n} answers to this poll? People can then vote again. This cannot be undone.`)) run(() => clearVotes(eventId, poll), "Answers cleared"); }}>
            Clear answers
          </button>
        )}
        <button className="btn btn-sm btn-quiet btn-danger" type="button" disabled={busy}
          onClick={() => { if (confirm("Delete this poll and its results? This cannot be undone.")) run(() => deletePoll(eventId, poll, event), "Poll deleted"); }}>
          Delete
        </button>
      </div>
    </li>
  );
}

function PollEditor({ eventId, polls, initial, type: startType, onClose }: { eventId: string; polls: Poll[]; initial?: Poll; type?: PollType; onClose: () => void }) {
  const [type, setType] = useState<PollType | null>(initial?.type ?? startType ?? null);
  const [question, setQuestion] = useState(initial?.question ?? "");
  const [options, setOptions] = useState<string[]>(initial?.options.length ? initial.options : ["", ""]);
  const [multi, setMulti] = useState(initial?.multi ?? false);
  const [scale, setScale] = useState(initial?.type === "rating" ? initial.max : 5);
  const [words, setWords] = useState(initial?.type === "wordcloud" ? initial.max : 3);
  const [error, setError] = useState("");
  const [busy, setBusy] = useState(false);

  const save = async () => {
    if (!type) return;
    const checked = checkPoll({ type, question, options, multi, max: type === "rating" ? scale : words });
    if ("error" in checked) { setError(checked.error); return; }
    const p = checked.poll;
    if (initial && initial.status !== "draft" && hasOptions(p.type) && JSON.stringify(p.options) !== JSON.stringify(initial.options)
      && !confirm("People have voted on these options. Votes point to option positions, so changing or reordering options can mix up the results. Clear the answers first if you change them. Save anyway?")) return;
    setBusy(true);
    try {
      if (initial) {
        await set(pollPath(eventId, initial.id), { question: p.question, options: JSON.stringify(p.options), multi: p.multi, max: p.max });
      } else {
        const pos = polls.reduce((m, q) => Math.max(m, q.pos + 1), 0);
        await set(pollPath(eventId, randomId(12)), { ...newPollDoc(p, pos), createdAt: serverTimestamp() });
      }
      onClose();
      toast(initial ? "Poll saved" : "Poll added");
    } catch (err) {
      setError(explain(err));
      setBusy(false);
    }
  };

  if (!type) return <Modal title="Add a poll" onClose={onClose}><TypePicker onPick={setType} /></Modal>;
  return (
    <Modal title={initial ? "Edit poll" : `New ${POLL_TYPES[type].toLowerCase()} poll`} onClose={onClose}
      footer={<><button className="btn" type="button" onClick={save} disabled={busy}>{busy ? "Saving..." : initial ? "Save" : "Add poll"}</button><button className="btn btn-quiet" type="button" onClick={onClose}>Cancel</button></>}>
      <form className="editor" onSubmit={(e) => { e.preventDefault(); save(); }}>
        <label className="field">
          <span>Question</span>
          <textarea className="textarea" rows={2} value={question} maxLength={LIMITS.question} autoFocus placeholder={type === "wordcloud" ? "One word that describes this team?" : type === "rating" ? "How useful was this session?" : "What should we cover next?"}
            onChange={(e) => { setQuestion(e.target.value); setError(""); }} />
        </label>
        {hasOptions(type) && (
          <div className="field">
            <span>Options</span>
            <div className="opts">
              {options.map((o, i) => (
                <div key={i} className="opt-row">
                  <input className="input" value={o} maxLength={LIMITS.option} placeholder={`Option ${i + 1}`} aria-label={`Option ${i + 1}`}
                    onChange={(e) => { const next = [...options]; next[i] = e.target.value; setOptions(next); setError(""); }} />
                  <button className="icon-btn" type="button" aria-label={`Remove option ${i + 1}`} disabled={options.length <= 2} onClick={() => setOptions(options.filter((_, j) => j !== i))}>
                    <svg width="16" height="16" viewBox="0 0 20 20" aria-hidden="true"><path d="M5 5l10 10M15 5 5 15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /></svg>
                  </button>
                </div>
              ))}
              {options.length < (type === "ranking" ? LIMITS.rankingOptions : LIMITS.options) && (
                <button className="btn btn-sm btn-quiet add-opt" type="button" onClick={() => setOptions([...options, ""])}>Add an option</button>
              )}
            </div>
          </div>
        )}
        {type === "choice" && (
          <label className="switch">
            <input type="checkbox" checked={multi} onChange={(e) => setMulti(e.target.checked)} />
            <span className="switch-track" aria-hidden="true" />
            People can pick more than one
          </label>
        )}
        {type === "rating" && (
          <label className="field">
            <span>Scale</span>
            <select className="select" value={scale} onChange={(e) => setScale(Number(e.target.value))}>
              {[3, 4, 5, 6, 7, 8, 9, 10].map((n) => <option key={n} value={n}>1 to {n}</option>)}
            </select>
          </label>
        )}
        {type === "wordcloud" && (
          <label className="field">
            <span>Words each person can give</span>
            <select className="select" value={words} onChange={(e) => setWords(Number(e.target.value))}>
              {[1, 2, 3, 4, 5].map((n) => <option key={n} value={n}>{n}</option>)}
            </select>
          </label>
        )}
        {error && <p className="q-error" role="alert">{error}</p>}
        {!initial && <p className="hint">New polls start as drafts the audience cannot see. Open voting when you are ready, or just move to the poll in present mode.</p>}
      </form>
    </Modal>
  );
}

const QA_SECTIONS: { status: QuestionStatus; title: string; empty?: string }[] = [
  { status: "pending", title: "Waiting for approval" },
  { status: "live", title: "Live, most upvoted first", empty: "No questions yet. They show up here as people ask them." },
  { status: "answered", title: "Answered" },
  { status: "hidden", title: "Hidden" },
];

const QA_ACTIONS: Record<QuestionStatus, [QuestionStatus, string][]> = {
  pending: [["live", "Approve"], ["hidden", "Hide"]],
  live: [["answered", "Mark answered"], ["hidden", "Hide"]],
  answered: [["live", "Back to live"]],
  hidden: [["live", "Show"]],
};

function Toggle({ checked, onChange, children }: { checked: boolean; onChange: (v: boolean) => void; children: string }) {
  return (
    <label className="switch">
      <input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
      <span className="switch-track" aria-hidden="true" />
      {children}
    </label>
  );
}

function AdminQA({ eventId, event, questions }: { eventId: string; event: EventDoc; questions: QuestionDoc[] | undefined }) {
  const update = async (doc: Partial<EventDoc>) => { try { await set(`events/${eventId}`, doc); } catch (err) { toast(explain(err)); } };
  const move = async (q: QuestionDoc, status: QuestionStatus) => {
    try { await set(`events/${eventId}/questions/${q.id}`, { status }); } catch (err) { toast(explain(err)); }
  };
  const sorted = useMemo(() => [...(questions || [])].sort(byVotes), [questions]);
  return (
    <div className="admin-qa">
      <div className="card qa-settings">
        <Toggle checked={event.qa} onChange={(qa) => update({ qa })}>Take questions from the audience</Toggle>
        <Toggle checked={event.moderate} onChange={(moderate) => update({ moderate })}>Approve each question before everyone sees it</Toggle>
        <div className="row">
          <button className="btn btn-sm btn-line" type="button" disabled={event.current === "qa"} onClick={() => showOnScreen(eventId, "qa").catch((e) => toast(explain(e)))}>
            {event.current === "qa" ? "Q&A is on screen" : "Show Q&A on screen"}
          </button>
          <button className="btn btn-sm btn-quiet" type="button" disabled={!sorted.length} onClick={() => download(questionsCsv(sorted), fileName(event.title, "questions"))}>Questions (CSV)</button>
        </div>
      </div>
      {questions === undefined ? <Loading text="Loading questions..." /> : QA_SECTIONS.map(({ status, title, empty }) => {
        const list = sorted.filter((q) => q.status === status);
        if (!list.length && !empty) return null;
        return (
          <section key={status} className="qa-section">
            <h2 className="h3">{title}{list.length ? ` · ${list.length}` : ""}</h2>
            {!list.length ? <p className="muted">{empty}</p> : (
              <ul className="qa-list">
                {list.map((q) => (
                  <li key={q.id} className={`card qa-item status-${q.status}`}>
                    <div className="qa-text">
                      <p>{q.text}</p>
                      <p className="qa-meta">{q.name || "Anonymous"} · {q.upvotes} {q.upvotes === 1 ? "upvote" : "upvotes"}</p>
                    </div>
                    <div className="qa-actions">
                      {QA_ACTIONS[q.status].map(([to, label]) => (
                        <button key={to} className={`btn btn-sm ${to === "hidden" ? "btn-quiet" : "btn-line"}`} type="button" onClick={() => move(q, to)}>{label}</button>
                      ))}
                    </div>
                  </li>
                ))}
              </ul>
            )}
          </section>
        );
      })}
      <p className="hint">The audience sees live and answered questions. People always see their own.</p>
    </div>
  );
}

function Settings({ eventId, event }: { eventId: string; event: EventDoc }) {
  const [title, setTitle] = useState(event.title);
  const [showKey, setShowKey] = useState(false);
  const url = adminUrl(eventId);
  const saveTitle = async () => {
    const checked = checkTitle(title);
    if ("error" in checked) { toast(checked.error); return; }
    try { await set(`events/${eventId}`, { title: checked.title }); toast("Name saved"); } catch (err) { toast(explain(err)); }
  };
  return (
    <div className="settings">
      <section className="card set-card">
        <h2 className="h3">Event name</h2>
        <form className="ask-row" onSubmit={(e) => { e.preventDefault(); saveTitle(); }}>
          <input className="input" value={title} maxLength={LIMITS.title} onChange={(e) => setTitle(e.target.value)} aria-label="Event name" />
          <button className="btn" type="submit" disabled={title.trim() === event.title}>Save</button>
        </form>
      </section>
      <section className="card set-card">
        <h2 className="h3">Voting</h2>
        <Toggle checked={event.allowChange} onChange={(allowChange) => set(`events/${eventId}`, { allowChange }).catch((e) => toast(explain(e)))}>
          Let people change their answer while a poll is open
        </Toggle>
        <p className="muted small">Each device votes once per poll either way. Someone who clears their browser or uses another device can vote again, so do not use Polls for elections.</p>
      </section>
      <section className="card set-card">
        <h2 className="h3">Audience link</h2>
        <p className="muted small">Anyone with this link or the code {formatCode(event.code)} can vote and ask questions.</p>
        <div className="link-row">
          <code className="link-code">{audienceUrl(eventId)}</code>
          <button className="btn btn-sm btn-line" type="button" onClick={() => copy(audienceUrl(eventId), "Audience link copied")}>Copy</button>
        </div>
      </section>
      <section className="card set-card share-admin">
        <h2 className="h3">Admin link</h2>
        <p className="muted small">Open it on another device to run this event there. Anyone with it can do the same and read every answer, so keep it private. It is the only way back in from a new browser.</p>
        {url ? (
          <div className="link-row">
            <code className="link-code">{showKey ? url : `${SITE_URL}/e/${eventId}/admin#k=••••••••`}</code>
            <span className="row link-actions">
              <button className="btn btn-sm btn-line" type="button" onClick={() => setShowKey((v) => !v)}>{showKey ? "Hide" : "Show"}</button>
              <button className="btn btn-sm btn-line" type="button" onClick={() => copy(url, "Admin link copied. Keep it private.")}>Copy</button>
            </span>
          </div>
        ) : <p className="notice">This browser does not have the admin link stored. Use the one you saved when you made the event.</p>}
      </section>
    </div>
  );
}

Change history

Point OpenApps links to openapps.free
c06cea4889 · 17 hours ago
Bind agent address in policy constants
a7de116d52 · 19 hours ago
Use OpenApps service URLs and add contribution strip
e8ce0b0bf0 · 19 hours ago
Bounded source sync
dcdc4d56e6 · 1 day ago
Bounded source sync
7acdace1e1 · 1 day ago
Bounded source sync
2140a66ea0 · 1 day ago
Bounded source sync
0db2fe3ca0 · 1 day ago
Bounded source sync
996f633a3c · 1 day ago