Merge branch 'fast-push' into mirroring
3 files+70−220/3 viewed
| 13 | 13 | return useRouteLoaderData("routes/workspace/docs/layout") as DocsLayoutData | undefined; | |
| 14 | 14 | } | |
| 15 | 15 | ||
| 16 | + | /** How long a Docs request may take before the page says so instead of waiting on. */ | |
| 17 | + | export const DOCS_TIMEOUT_MS = 20_000; | |
| 18 | + | ||
| 19 | + | /** What a request that failed or took too long says. */ | |
| 20 | + | export function docsFailure<T>(error: unknown): Result<T> { | |
| 21 | + | const late = error instanceof DOMException && (error.name === "TimeoutError" || error.name === "AbortError"); | |
| 22 | + | const message = late ? "Docs took too long to answer. Try again in a moment." : "Docs didn't answer. Check your connection and try again."; | |
| 23 | + | return { ok: false, error: { code: "conflict", message } }; | |
| 24 | + | } | |
| 25 | + | ||
| 16 | 26 | export async function docsRequest<T>(slug: string, intent: string, body: Record<string, unknown> = {}): Promise<Result<T>> { | |
| 17 | 27 | try { | |
| 18 | 28 | const response = await fetch(`/${slug}/-/docs/api`, { | |
| 19 | 29 | method: "POST", | |
| 20 | 30 | headers: { "content-type": "application/json", accept: "application/json" }, | |
| 21 | 31 | body: JSON.stringify({ intent, ...body }), | |
| 32 | + | signal: AbortSignal.timeout(DOCS_TIMEOUT_MS), | |
| 22 | 33 | }); | |
| 23 | 34 | return (await response.json()) as Result<T>; | |
| 24 | − | } catch { | |
| 25 | − | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } }; | |
| 35 | + | } catch (error) { | |
| 36 | + | return docsFailure(error); | |
| 26 | 37 | } | |
| 27 | 38 | } | |
| 28 | 39 | ||
| 29 | 40 | export async function docsQuery<T>(slug: string, query: Record<string, string>): Promise<Result<T>> { | |
| 30 | 41 | try { | |
| 31 | − | const response = await fetch(`/${slug}/-/docs/api?${new URLSearchParams(query)}`, { headers: { accept: "application/json" } }); | |
| 42 | + | const response = await fetch(`/${slug}/-/docs/api?${new URLSearchParams(query)}`, { | |
| 43 | + | headers: { accept: "application/json" }, | |
| 44 | + | signal: AbortSignal.timeout(DOCS_TIMEOUT_MS), | |
| 45 | + | }); | |
| 32 | 46 | return (await response.json()) as Result<T>; | |
| 33 | − | } catch { | |
| 34 | − | return { ok: false, error: { code: "conflict", message: "Docs didn't answer. Check your connection and try again." } }; | |
| 47 | + | } catch (error) { | |
| 48 | + | return docsFailure(error); | |
| 35 | 49 | } | |
| 36 | 50 | } | |
| 37 | 51 |
| 50 | 50 | const { revalidate } = useRevalidator(); | |
| 51 | 51 | const general = layout?.sidebar?.spaces.find((s) => s.is_default) ?? layout?.sidebar?.spaces.find((s) => canDo(s.viewer_role, "edit")); | |
| 52 | 52 | const spacesById = new Map((home?.spaces ?? []).map((s) => [s.id, s])); | |
| 53 | + | // What a new page is being started from ("" for a blank one), while it is. | |
| 54 | + | const [starting, setStarting] = useState<string | null>(null); | |
| 53 | 55 | const create = async (template?: string) => { | |
| 54 | − | if (!general) return; | |
| 56 | + | if (!general || starting != null) return; | |
| 57 | + | setStarting(template ?? ""); | |
| 58 | + | setError(null); | |
| 55 | 59 | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: general.id, template_id: template ?? null } }); | |
| 56 | − | if (made.ok) navigate(made.value.path); | |
| 57 | − | else setError(made.error.message); | |
| 60 | + | if (made.ok) { | |
| 61 | + | await navigate(made.value.path); | |
| 62 | + | return; | |
| 63 | + | } | |
| 64 | + | setStarting(null); | |
| 65 | + | setError(made.error.message); | |
| 58 | 66 | }; | |
| 59 | 67 | if (!home) { | |
| 60 | 68 | return ( | |
| ⋯ | |||
| 89 | 97 | <Plus size={15} /> New space | |
| 90 | 98 | </ButtonLink> | |
| 91 | 99 | {general && ( | |
| 92 | − | <button type="button" onClick={() => create()} className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover"> | |
| 93 | − | <FilePlus2 size={15} /> New page | |
| 100 | + | <button | |
| 101 | + | type="button" | |
| 102 | + | disabled={starting != null} | |
| 103 | + | aria-busy={starting === ""} | |
| 104 | + | onClick={() => create()} | |
| 105 | + | className="inline-flex h-9 items-center gap-1.5 rounded-md bg-accent px-3 text-sm font-medium text-bg transition-colors hover:bg-accent-hover disabled:opacity-60" | |
| 106 | + | > | |
| 107 | + | <FilePlus2 size={15} /> {starting === "" ? "Starting…" : "New page"} | |
| 94 | 108 | </button> | |
| 95 | 109 | )} | |
| 96 | 110 | </div> | |
| ⋯ | |||
| 210 | 224 | key={t.id} | |
| 211 | 225 | template={t} | |
| 212 | 226 | action={ | |
| 213 | − | <button type="button" onClick={() => create(t.id)} className="text-xs font-medium text-accent hover:underline"> | |
| 214 | − | Use in {general.name} | |
| 227 | + | <button | |
| 228 | + | type="button" | |
| 229 | + | disabled={starting != null} | |
| 230 | + | aria-busy={starting === t.id} | |
| 231 | + | onClick={() => create(t.id)} | |
| 232 | + | className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline" | |
| 233 | + | > | |
| 234 | + | {starting === t.id ? "Starting…" : `Use in ${general.name}`} | |
| 215 | 235 | </button> | |
| 216 | 236 | } | |
| 217 | 237 | /> | |
| 32 | 32 | const writable = (layout?.sidebar?.spaces ?? []).filter((s) => canDo(s.viewer_role, "edit")); | |
| 33 | 33 | const [space, setSpace] = useState(writable.find((s) => s.is_default)?.id ?? writable[0]?.id ?? ""); | |
| 34 | 34 | const [error, setError] = useState<string | null>(null); | |
| 35 | + | // The template a page is being started from, while it is. | |
| 36 | + | const [starting, setStarting] = useState<string | null>(null); | |
| 35 | 37 | const use = async (template: string) => { | |
| 38 | + | if (starting) return; | |
| 39 | + | setStarting(template); | |
| 40 | + | setError(null); | |
| 36 | 41 | const made = await docsRequest<{ path: string }>(slug, "create_page", { page: { space_id: space, template_id: template } }); | |
| 37 | − | if (made.ok) navigate(made.value.path); | |
| 38 | − | else setError(made.error.message); | |
| 42 | + | if (made.ok) { | |
| 43 | + | await navigate(made.value.path); | |
| 44 | + | return; | |
| 45 | + | } | |
| 46 | + | setStarting(null); | |
| 47 | + | setError(made.error.message); | |
| 39 | 48 | }; | |
| 49 | + | const startButton = (id: string) => ( | |
| 50 | + | <button | |
| 51 | + | type="button" | |
| 52 | + | disabled={!space || starting != null} | |
| 53 | + | aria-busy={starting === id} | |
| 54 | + | onClick={() => use(id)} | |
| 55 | + | className="text-xs font-medium text-accent hover:underline disabled:text-faint disabled:no-underline" | |
| 56 | + | > | |
| 57 | + | {starting === id ? "Starting…" : "Use template"} | |
| 58 | + | </button> | |
| 59 | + | ); | |
| 40 | 60 | const builtin = loaderData.templates.filter((t) => t.builtin); | |
| 41 | 61 | const own = loaderData.templates.filter((t) => !t.builtin); | |
| 42 | 62 | return ( | |
| ⋯ | |||
| 65 | 85 | <TemplateCard | |
| 66 | 86 | key={t.id} | |
| 67 | 87 | template={t} | |
| 68 | − | action={ | |
| 69 | − | <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint"> | |
| 70 | − | Use template | |
| 71 | − | </button> | |
| 72 | − | } | |
| 88 | + | action={startButton(t.id)} | |
| 73 | 89 | /> | |
| 74 | 90 | ))} | |
| 75 | 91 | </div> | |
| ⋯ | |||
| 86 | 102 | template={t} | |
| 87 | 103 | action={ | |
| 88 | 104 | <> | |
| 89 | − | <button type="button" disabled={!space} onClick={() => use(t.id)} className="text-xs font-medium text-accent hover:underline disabled:text-faint"> | |
| 90 | − | Use template | |
| 91 | − | </button> | |
| 105 | + | {startButton(t.id)} | |
| 92 | 106 | <button type="button" onClick={() => send("delete_template", { template_id: t.id })} className="ml-auto text-xs text-faint hover:text-danger"> | |
| 93 | 107 | Delete | |
| 94 | 108 | </button> | |