diff --git a/apps/docs/openapi/public.yaml b/apps/docs/openapi/public.yaml index 2b9cf948..11075cc6 100644 --- a/apps/docs/openapi/public.yaml +++ b/apps/docs/openapi/public.yaml @@ -42,13 +42,13 @@ actions: input_format: png target_format: webp input_bytes: 48213 - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.input_format + - target: $.components.schemas.CreateJob.properties.input_format update: description: Format id of the file you upload, such as `png` or `docx`. See [Formats](https://convt.app/docs/reference/formats). - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.target_format + - target: $.components.schemas.CreateJob.properties.target_format update: description: Format id to convert to. It must differ from `input_format` and be a target the input can reach. - - target: $.paths['/v1/jobs'].post.requestBody.content['application/json'].schema.properties.input_bytes + - target: $.components.schemas.CreateJob.properties.input_bytes update: description: Exact size of the upload in bytes. - target: $.paths['/v1/jobs'].post.responses['200'] @@ -68,10 +68,10 @@ actions: expires_at: "2026-10-08T12:00:00Z" upload_url: https://bucket.example/job_01k6z7v4q8m3x2a9b5c0d1e2f3/upload?X-Amz-Signature=… upload_expires_in: 900 - - target: $.paths['/v1/jobs'].post.responses['200'].content['application/json'].schema.properties.upload_url + - target: $.components.schemas.JobReservation.properties.upload_url update: description: Signed URL. Send the file bytes to it with `PUT`. - - target: $.paths['/v1/jobs'].post.responses['200'].content['application/json'].schema.properties.upload_expires_in + - target: $.components.schemas.JobReservation.properties.upload_expires_in update: description: Seconds until `upload_url` stops accepting uploads. Always 900. - target: $.paths['/v1/jobs'].post.responses['400'] @@ -225,26 +225,12 @@ actions: extensions: [webp] mime: image/webp - # Shared - - target: $.paths.*.*.parameters[?@.name == 'id'] + # Shared. The 401, 403, 404, 429, 500 and 502 responses every job route shares are + # components in the spec and carry their own descriptions. + - target: $.components.parameters.JobId update: description: The job id returned when you created the job, such as `job_01k6z7v4q8m3x2a9b5c0d1e2f3`. example: job_01k6z7v4q8m3x2a9b5c0d1e2f3 - - target: $.paths.*.*.responses['401'] - update: - description: "`unauthorized`: no `Authorization: Bearer` header." - - target: $.paths.*.*.responses['403'] - update: - description: "`unauthorized` (key invalid or revoked) or a billing limit." - - target: $.paths.*.*.responses['404'] - update: - description: "`not_found`: the job does not exist, belongs to another account, or has expired." - - target: $.paths.*.*.responses['429'] - update: - description: "`rate_limited`: more than 120 requests in a minute for this key." - - target: $.paths.*.*.responses['502'] - update: - description: "`storage_unavailable`: object storage did not answer. Retry with backoff." - target: $.components.securitySchemes.bearerAuth update: description: "A `cvt_live_` API key from the [dashboard](https://convt.app/dashboard/api), sent as `Authorization: Bearer cvt_live_…`. Keep it on your server." diff --git a/apps/docs/scripts/check-overlay.ts b/apps/docs/scripts/check-overlay.ts new file mode 100644 index 00000000..4cce0a51 --- /dev/null +++ b/apps/docs/scripts/check-overlay.ts @@ -0,0 +1,35 @@ +// Fails the build when an action in openapi/public.yaml targets nothing in the spec. +// Blume skips unmatched targets silently, so a spec refactor (an inline schema moving +// to components, say) would otherwise drop the overlay's prose without a warning. +// Targets are plain paths: `$`, `.name` and `['name']`. Wildcards and filters are +// refused because they can match nothing without being wrong. + +const spec = await Bun.file( + new URL("../../../crates/convt-server/openapi.json", import.meta.url), +).json(); +const overlay = Bun.YAML.parse( + await Bun.file(new URL("../openapi/public.yaml", import.meta.url)).text(), +) as { actions: { target: string }[] }; + +const segment = /\.([A-Za-z_$][\w$-]*)|\['([^']+)'\]/y; + +function resolve(target: string): unknown { + if (!target.startsWith("$")) throw new Error(`${target}: targets start with $`); + let value: unknown = spec; + segment.lastIndex = 1; + while (segment.lastIndex < target.length) { + const at = segment.lastIndex; + const match = segment.exec(target); + if (!match) throw new Error(`${target}: unsupported syntax at "${target.slice(at)}"`); + value = (value as Record | undefined)?.[match[1] ?? match[2]]; + } + return value; +} + +const unmatched = overlay.actions + .map((a) => a.target) + .filter((target) => resolve(target) === undefined); +if (unmatched.length) { + throw new Error(`openapi/public.yaml targets nothing in the spec:\n ${unmatched.join("\n ")}`); +} +console.log(`public.yaml: ${overlay.actions.length} overlay targets match the spec`); diff --git a/apps/docs/scripts/generate.ts b/apps/docs/scripts/generate.ts index 5909c8bc..e88e4e07 100644 --- a/apps/docs/scripts/generate.ts +++ b/apps/docs/scripts/generate.ts @@ -20,3 +20,4 @@ actions: await Bun.write(new URL("../openapi/servers.yaml", import.meta.url), servers); await import("./generate-formats.ts"); +await import("./check-overlay.ts"); diff --git a/apps/web/src/components/app/code-panel.tsx b/apps/web/src/components/app/code-panel.tsx new file mode 100644 index 00000000..38ca7a44 --- /dev/null +++ b/apps/web/src/components/app/code-panel.tsx @@ -0,0 +1,304 @@ +import { + createContext, + useContext, + useEffect, + useId, + useRef, + useState, + type ReactNode, +} from "react"; + +import type { Language, Sample } from "#/lib/api-samples"; + +import { cx } from "./ui"; + +// Dark code surface used by the API reference and the dashboard, in both themes. The +// colors are the landing page's code palette (components/landing/pricing.tsx). + +type Token = { text: string; tone?: keyof typeof tones }; +const tones = { + comment: "text-[#838985] italic", + string: "text-[#f2c46d]", + keyword: "text-[#7fd3a6]", + number: "text-[#f5a97f]", + property: "text-[#9cc9ff]", + flag: "text-[#9cc9ff]", + variable: "text-[#c9b6ff]", +}; + +const keywords: Record = { + node: /^(?:import|from|const|let|await|async|function|return|if|throw|new|while|of|in)$/, + python: /^(?:import|def|return|if|not|in|while|raise|for|None|True|False)$/, + curl: /^(?:while|do|done|case|esac|in|break|if|then|fi|curl|jq|xargs|sleep|wc|tr|convt)$/, + cli: /^(?:convt)$/, + json: /^(?:true|false|null)$/, +}; + +const patterns: Record = { + node: /(\/\/[^\n]*)|("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)|(\b\d[\d_.]*\b)|([A-Za-z_$][\w$]*)/g, + python: /(#[^\n]*)|(f?"(?:\\.|[^"\\])*"|f?'(?:\\.|[^'\\])*')|(\b\d[\d_.]*\b)|([A-Za-z_][\w]*)/g, + curl: /((?:^|(?<=\s))#[^\n]*)|("(?:\\.|[^"\\])*"|'[^']*')|(\s--?[a-zA-Z][\w-]*)|(\$\(?[A-Za-z_][\w]*|\$\{[^}]+\})|([A-Za-z_][\w]*)/g, + json: /("(?:\\.|[^"\\])*")(\s*:)?|(-?\b\d[\d.]*\b)|([a-z]+)/g, +}; + +export function tokenize(code: string, language: Language | "json"): Token[] { + const lang = language === "cli" ? "curl" : language; + const pattern = new RegExp(patterns[lang].source, "g"); + const tokens: Token[] = []; + let last = 0; + for (const m of code.matchAll(pattern)) { + const index = m.index ?? 0; + if (index > last) tokens.push({ text: code.slice(last, index) }); + last = index + m[0].length; + if (lang === "json") { + if (m[1]) { + tokens.push({ text: m[1], tone: m[2] ? "property" : "string" }); + if (m[2]) tokens.push({ text: m[2] }); + } else if (m[3]) tokens.push({ text: m[3], tone: "number" }); + else tokens.push({ text: m[0], tone: keywords.json.test(m[0]) ? "keyword" : undefined }); + continue; + } + if (lang === "curl") { + const [, comment, string, flag, variable] = m; + const tone = comment + ? "comment" + : string + ? "string" + : flag + ? "flag" + : variable + ? "variable" + : keywords.curl.test(m[0]) + ? "keyword" + : undefined; + tokens.push({ text: m[0], tone }); + continue; + } + const [, comment, string, number] = m; + const tone = comment + ? "comment" + : string + ? "string" + : number + ? "number" + : keywords[lang].test(m[0]) + ? "keyword" + : undefined; + tokens.push({ text: m[0], tone }); + } + if (last < code.length) tokens.push({ text: code.slice(last) }); + return tokens; +} + +export function Highlighted({ code, language }: { code: string; language: Language | "json" }) { + return ( + <> + {tokenize(code, language).map((t, i) => + t.tone ? ( + + {t.text} + + ) : ( + t.text + ), + )} + + ); +} + +const LanguageContext = createContext<{ + language: Language; + setLanguage: (l: Language) => void; +} | null>(null); +const storageKey = "convt:code-language"; + +/** Shares the chosen language across every CodePanel inside it and remembers it. */ +export function LanguageProvider({ children }: { children: ReactNode }) { + const [language, setState] = useState("curl"); + useEffect(() => { + const saved = localStorage.getItem(storageKey); + if (saved === "curl" || saved === "node" || saved === "python" || saved === "cli") + setState(saved); + }, []); + const setLanguage = (l: Language) => { + setState(l); + try { + localStorage.setItem(storageKey, l); + } catch {} + }; + return {children}; +} + +function CopyIcon() { + return ( + + ); +} + +export function CopyCode({ value, className }: { value: string; className?: string }) { + const [copied, setCopied] = useState(false); + const timer = useRef>(undefined); + useEffect(() => () => clearTimeout(timer.current), []); + return ( + + ); +} + +/** + * Tabbed code block. Inside a LanguageProvider the tab follows the shared choice when the + * panel has that language; on its own it keeps local state. + */ +export function CodePanel({ + samples, + title, + className, + maxHeight, +}: { + samples: Sample[]; + title?: string; + className?: string; + /** Tailwind max-height class for the code area; it scrolls past that. */ + maxHeight?: string; +}) { + const base = useId(); + const shared = useContext(LanguageContext); + const [local, setLocal] = useState(samples[0].language); + const wanted = shared?.language ?? local; + const current = samples.find((s) => s.language === wanted) ?? samples[0]; + const tabs = useRef>([]); + const choose = (l: Language) => (shared ? shared.setLanguage(l) : setLocal(l)); + + function onKeyDown(event: React.KeyboardEvent, index: number) { + const n = samples.length; + const next = + event.key === "ArrowRight" + ? (index + 1) % n + : event.key === "ArrowLeft" + ? (index - 1 + n) % n + : event.key === "Home" + ? 0 + : event.key === "End" + ? n - 1 + : -1; + if (next < 0) return; + event.preventDefault(); + choose(samples[next].language); + tabs.current[next]?.focus(); + } + + return ( +
+
+
+ {title && ( + {title} + )} + {samples.length > 1 && ( +
+ {samples.map((sample, index) => { + const selected = sample.language === current.language; + return ( + + ); + })} +
+ )} +
+ +
+
1 ? "tabpanel" : undefined} + id={`${base}-panel`} + aria-labelledby={samples.length > 1 ? `${base}-tab-${current.language}` : undefined} + tabIndex={0} + className={cx( + "overflow-auto px-4 py-4 outline-none focus-visible:ring-2 focus-visible:ring-[#3fcb84] focus-visible:ring-inset", + maxHeight, + )} + > +
+          
+            
+          
+        
+
+
+ ); +} + +/** Untabbed JSON block, for example responses. */ +export function JsonPanel({ value, title }: { value: unknown; title: string }) { + const code = JSON.stringify(value, null, 2); + return ( +
+
+ {title} + +
+
+
+          
+            
+          
+        
+
+
+ ); +} diff --git a/apps/web/src/components/app/code-sample.tsx b/apps/web/src/components/app/code-sample.tsx deleted file mode 100644 index 85c571a6..00000000 --- a/apps/web/src/components/app/code-sample.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { useId, useRef, useState } from "react"; - -import { apiBaseUrl } from "#/lib/config"; - -import { cx } from "./ui"; - -// Each example follows the jobs API. The SDK handles upload and polling. -const samples = [ - { - id: "node", - label: "Node", - code: `import { Convt } from "@convt/sdk"; - -// Set CONVT_API_KEY in your environment. -const convt = new Convt(); -const out = await convt.convert("report.docx", { - to: "pdf", -}); -await out.save("report.pdf");`, - }, - { - id: "curl", - label: "cURL", - code: `# Create a reservation for a 104-byte SVG. -job=$(curl -fsS ${apiBaseUrl}/v1/jobs \\ - -H "Authorization: Bearer $CONVT_KEY" \\ - -H "Content-Type: application/json" \\ - -d '{"input_format":"svg","target_format":"png","input_bytes":104}') -# PUT your file to upload_url, then POST /v1/jobs/{id}/start. -# Poll GET /v1/jobs/{id}; on success, GET its /download URLs. -# The SDK performs each of these steps for you.`, - }, - { - id: "browser", - label: "Browser", - code: `import { Convt } from "@convt/sdk"; - -// Use a short-lived token issued by your server. -const convt = new Convt({ - token: () => fetchToken(), - baseUrl: "${apiBaseUrl}", -}); -const out = await convt.convert(file, { to: "pdf" }); -const blob = await out.blob();`, - }, - { - id: "cli", - label: "CLI", - code: `# The desktop CLI converts on your machine. -# It needs no API key and uploads nothing. -convt report.docx --to pdf`, - }, -] as const; - -type SampleId = (typeof samples)[number]["id"]; - -export function CodeSample() { - const base = useId(); - const [active, setActive] = useState("node"); - const [copied, setCopied] = useState(false); - const tabRefs = useRef>([]); - const current = samples.find((s) => s.id === active) ?? samples[0]; - - function onKeyDown(event: React.KeyboardEvent, index: number) { - let next = index; - if (event.key === "ArrowRight") next = (index + 1) % samples.length; - else if (event.key === "ArrowLeft") next = (index - 1 + samples.length) % samples.length; - else if (event.key === "Home") next = 0; - else if (event.key === "End") next = samples.length - 1; - else return; - event.preventDefault(); - setActive(samples[next].id); - setCopied(false); - tabRefs.current[next]?.focus(); - } - - async function copy() { - try { - await navigator.clipboard.writeText(current.code); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch { - setCopied(false); - } - } - - return ( -
-
-
- {samples.map((sample, index) => { - const selected = sample.id === active; - return ( - - ); - })} -
- -
-
-
-          {current.code}
-        
-
-
- ); -} diff --git a/apps/web/src/components/app/ui.tsx b/apps/web/src/components/app/ui.tsx index fa5d721c..8e2098ef 100644 --- a/apps/web/src/components/app/ui.tsx +++ b/apps/web/src/components/app/ui.tsx @@ -114,6 +114,43 @@ export function Badge({ ); } +/** + * Horizontal usage bar: `used` in solid green, `reserved` after it in a lighter green. + * Values are fractions of `limit`; the bar clamps at full. + */ +export function Meter({ + used, + reserved = 0, + limit, + label, +}: { + used: number; + reserved?: number; + limit: number; + label: string; +}) { + const part = (n: number) => (limit > 0 ? Math.min(100, Math.max(0, (n / limit) * 100)) : 0); + const usedPct = part(used); + const reservedPct = Math.min(100 - usedPct, part(reserved)); + const full = limit > 0 && used + reserved >= limit; + return ( +
+ + +
+ ); +} + /** Classes for the bordered tables (invoices, keys, sign-in methods, sessions). */ export const table = { /** Scroll wrapper so wide tables stay usable on a phone. */ diff --git a/apps/web/src/lib/api-samples.ts b/apps/web/src/lib/api-samples.ts new file mode 100644 index 00000000..5ab7a86d --- /dev/null +++ b/apps/web/src/lib/api-samples.ts @@ -0,0 +1,132 @@ +// Quick-start samples for the dashboard. Each is a complete program that runs the whole +// job flow: create, upload, start, poll, download. They use plain HTTP because +// @convt/sdk is not published to npm yet. + +export type Language = "curl" | "node" | "python" | "cli"; +export type Sample = { language: Language; label: string; code: string }; + +export const languageLabels: Record = { + curl: "cURL", + node: "Node.js", + python: "Python", + cli: "CLI", +}; + +export function quickStart(base: string): Sample[] { + return [ + { + language: "curl", + label: languageLabels.curl, + code: `# Needs curl 7.76+ and jq. Converts photo.png to photo.webp. +API=${base} +AUTH="Authorization: Bearer $CONVT_API_KEY" +size=$(wc -c < photo.png | tr -d ' ') + +# 1. Reserve the job and get an upload URL +created=$(curl -sS --fail-with-body "$API/v1/jobs" -H "$AUTH" \\ + -H "Content-Type: application/json" \\ + -d "{\\"input_format\\":\\"png\\",\\"target_format\\":\\"webp\\",\\"input_bytes\\":$size}") +id=$(jq -r .job.id <<<"$created") + +# 2. Upload exactly input_bytes, then start +curl -sS --fail-with-body -X PUT --upload-file photo.png "$(jq -r .upload_url <<<"$created")" +curl -sS --fail-with-body -X POST "$API/v1/jobs/$id/start" -H "$AUTH" + +# 3. Poll every 2 seconds until the job finishes +while :; do + status=$(curl -sS --fail-with-body "$API/v1/jobs/$id" -H "$AUTH" | jq -r .status) + case $status in succeeded|failed|cancelled) break ;; esac + sleep 2 +done + +# 4. Download the output +curl -sS --fail-with-body "$API/v1/jobs/$id/download" -H "$AUTH" \\ + | jq -r '.outputs[0].url' | xargs curl -sS --fail-with-body -o photo.webp`, + }, + { + language: "node", + label: languageLabels.node, + code: `// Node 18 or newer, as an ES module. Converts photo.png to photo.webp. +import { readFile, writeFile } from "node:fs/promises"; + +const API = "${base}"; +const auth = { Authorization: \`Bearer \${process.env.CONVT_API_KEY}\` }; + +async function api(path, init = {}) { + const res = await fetch(API + path, { ...init, headers: { ...auth, ...init.headers } }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(body.error?.code ?? \`HTTP \${res.status}\`); + return body; +} + +// 1. Reserve the job and get an upload URL +const file = await readFile("photo.png"); +const { job, upload_url } = await api("/v1/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ input_format: "png", target_format: "webp", input_bytes: file.byteLength }), +}); + +// 2. Upload exactly input_bytes, then start +const put = await fetch(upload_url, { method: "PUT", body: file }); +if (!put.ok) throw new Error(\`upload failed: \${put.status}\`); +let current = await api(\`/v1/jobs/\${job.id}/start\`, { method: "POST" }); + +// 3. Poll every 2 seconds until the job finishes +while (!["succeeded", "failed", "cancelled"].includes(current.status)) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + current = await api(\`/v1/jobs/\${job.id}\`); +} +if (current.status !== "succeeded") throw new Error(current.error_code ?? current.status); + +// 4. Download the output +const { outputs } = await api(\`/v1/jobs/\${job.id}/download\`); +const output = await fetch(outputs[0].url); +await writeFile("photo.webp", Buffer.from(await output.arrayBuffer()));`, + }, + { + language: "python", + label: languageLabels.python, + code: `# Python 3 with requests. Converts photo.png to photo.webp. +import os, time, requests + +API = "${base}" +AUTH = {"Authorization": f"Bearer {os.environ['CONVT_API_KEY']}"} + +def api(method, path, **kwargs): + res = requests.request(method, API + path, headers=AUTH, **kwargs) + if not res.ok: + raise RuntimeError(f"{res.status_code}: {res.text}") + return res.json() + +# 1. Reserve the job and get an upload URL +data = open("photo.png", "rb").read() +created = api("POST", "/v1/jobs", json={ + "input_format": "png", "target_format": "webp", "input_bytes": len(data), +}) +job_id = created["job"]["id"] + +# 2. Upload exactly input_bytes, then start +requests.put(created["upload_url"], data=data).raise_for_status() +job = api("POST", f"/v1/jobs/{job_id}/start") + +# 3. Poll every 2 seconds until the job finishes +while job["status"] not in ("succeeded", "failed", "cancelled"): + time.sleep(2) + job = api("GET", f"/v1/jobs/{job_id}") +if job["status"] != "succeeded": + raise RuntimeError(job["error_code"] or job["status"]) + +# 4. Download the output +url = api("GET", f"/v1/jobs/{job_id}/download")["outputs"][0]["url"] +open("photo.webp", "wb").write(requests.get(url).content)`, + }, + { + language: "cli", + label: languageLabels.cli, + code: `# The convt CLI ships with the desktop app and converts on your machine. +# It needs no API key and uploads nothing. +convt photo.png --to webp`, + }, + ]; +} diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index dcbeb798..54565f7f 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -2,9 +2,14 @@ // PLACEHOLDER do not exist yet; swap them when the real services are live. export const links = { - /** The API reference on this site (renders convt-server's OpenAPI spec). */ - docs: "/docs/api", + /** The Blume docs site (apps/docs), served on convt.app/docs* by its own Worker. */ + docs: "/docs", + /** Blume's reference, rendered from crates/convt-server/openapi.json. */ apiReference: "/docs/api", + docsQuickStart: "/docs/quick-start", + docsErrors: "/docs/reference/errors", + docsLimits: "/docs/reference/limits", + docsFormats: "/docs/reference/formats", formats: "/formats", /** PLACEHOLDER: webhooks are not designed in the API yet. */ webhooks: "https://docs.convt.app/webhooks", diff --git a/apps/web/src/routes/_app/_shell/dashboard/api.tsx b/apps/web/src/routes/_app/_shell/dashboard/api.tsx index aafeb2e6..b4dcdd29 100644 --- a/apps/web/src/routes/_app/_shell/dashboard/api.tsx +++ b/apps/web/src/routes/_app/_shell/dashboard/api.tsx @@ -1,15 +1,16 @@ -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { addApiKey, removeApiKey, fetchApiSpend } from "#/server/cloud-fns"; import { createFileRoute, useRouter } from "@tanstack/react-router"; import { ApiEnrollmentCard } from "#/components/app/api-enrollment"; -import { CodeSample } from "#/components/app/code-sample"; +import { CodePanel, CopyCode } from "#/components/app/code-panel"; import { CopyButton } from "#/components/app/copy-button"; import { UsageChart } from "#/components/app/usage-chart"; import { Card, ChevronIcon, ExternalIcon, + Meter, PageTitle, PrimaryButton, SecondaryLink, @@ -17,11 +18,11 @@ import { TextButton, cx, focusRing, - table, } from "#/components/app/ui"; import { getApiOverview } from "#/lib/account"; -import { links } from "#/lib/config"; -import { formatDate, formatNumber, formatShortDate } from "#/lib/format"; +import { quickStart } from "#/lib/api-samples"; +import { apiBaseUrl, links } from "#/lib/config"; +import { formatDate, formatMoney, formatNumber, formatShortDate } from "#/lib/format"; export const Route = createFileRoute("/_app/_shell/dashboard/api")({ head: () => ({ meta: [{ title: "API · convt" }] }), @@ -30,9 +31,26 @@ export const Route = createFileRoute("/_app/_shell/dashboard/api")({ }); const docLinks = [ - { href: links.apiReference, title: "API reference", body: "Endpoints, options and errors" }, - { href: links.formats, title: "Supported formats", body: "Every input and target the API takes" }, - { href: links.apiReference, title: "Jobs and limits", body: "Upload, start, poll and download" }, + { + href: links.docsQuickStart, + title: "Quick start", + body: "Your first conversion in four requests", + }, + { + href: links.docsErrors, + title: "Errors", + body: "Every error code and when to retry", + }, + { + href: links.docsLimits, + title: "Limits and billing", + body: "File size, rate limit, retention", + }, + { + href: links.docsFormats, + title: "Supported conversions", + body: "Every pair the cloud converts", + }, ]; function ApiPage() { @@ -41,8 +59,13 @@ function ApiPage() { const [creating, setCreating] = useState(false); const [name, setName] = useState(""); const [shownKey, setShownKey] = useState(null); + const [confirming, setConfirming] = useState(null); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const onSale = api.sales === "all"; + const enrolled = api.enrollment.state === "enrolled"; + const canCreate = onSale && enrolled; + async function create() { setBusy(true); setError(""); @@ -63,6 +86,7 @@ function ApiPage() { setError(""); try { await removeApiKey({ data: { id } }); + setConfirming(null); await router.invalidate(); } catch { setError("Revoking the key failed. Try again."); @@ -71,205 +95,324 @@ function ApiPage() { } } + const { used, reserved, limit } = api.spend; + const capReached = api.spend.allowed && used + reserved >= limit; + return ( -
-
+
+
API -

- Convert files from your own code. Billed per conversion at the end of each month. +

+ Convert files from your own code with the same engines as the app. Billed per successful + conversion at the end of each month.

-
- - API docs +
+ + API reference - { - setCreating(true); - setShownKey(null); - }} - > - {api.sales === "all" ? "Create key" : "Coming soon"} - + {onSale ? ( + + Cloud converter + + ) : null}
-
+
- {api.sales === "all" ? ( - - Convert in your browser - - ) : ( -

Cloud conversions are coming soon.

+ {!onSale && ( + + )} + +
+ +
+ {formatMoney(used)} +
+ {limit > 0 ? ( + <> +
+ +
+
+ {capReached + ? "Spend cap reached. Raise it under API billing to create more jobs." + : `of ${formatMoney(limit)} cap${reserved > 0 ? ` · ${formatMoney(reserved)} reserved` : ""}`} +
+ + ) : ( +
No spend cap yet
+ )} +
+ +
+ {formatNumber(api.thisMonth)} +
+
Since {formatShortDate(api.since)}
+
+ +
+ {formatNumber(api.last30Days)} +
+
0 ? "text-error" : "text-ink-3")}> + {formatNumber(api.failed)} failed, not charged +
+
+
+ {error && (

{error}

)} - {creating && ( - - - setName(e.target.value)} - placeholder="Production server" - className={`rounded-lg border border-line bg-page px-3 py-2 text-sm ${focusRing}`} - /> -
- - {busy ? "Creating…" : "Create API key"} - - setCreating(false)}>Cancel -
-
- )} - {shownKey && ( - - Your new API key -

Save this key now. It will not be shown again.

-
- e.target.select()} - className={`min-w-0 w-full rounded-lg border border-line bg-page px-3 py-2 font-mono text-xs ${focusRing}`} - /> - -
- setShownKey(null)}> - I saved the key - -
- )} - -
- Spent - ${(api.spend.used / 100).toFixed(2)} -
-
- Reserved - ${(api.spend.reserved / 100).toFixed(2)} -
-
- Spend cap - ${(api.spend.limit / 100).toFixed(2)} -
- {api.spend.allowed && api.spend.used + api.spend.reserved >= api.spend.limit && ( -

- Spend cap reached. Raise it under API billing to create more jobs. -

- )} -
- - -
-
-
This month
-
- {formatNumber(api.thisMonth)} -
-
Conversions since {formatShortDate(api.since)}
-
-
-
Last 30 days
-
{formatNumber(api.last30Days)}
-
-
-
Failed
-
{formatNumber(api.failed)}
-
-
-
- +
+
+
+ +
+
+ Keys +

+ Send a key as Authorization: Bearer. + Keep keys on your server. +

+
+ {!creating && ( + { + setCreating(true); + setShownKey(null); + }} + > + {onSale ? "Create key" : "Coming soon"} + + )} +
+ + {creating && ( +
{ + e.preventDefault(); + if (name.trim()) void create(); + }} + > +
+ + setName(e.target.value)} + placeholder="Production server" + className={cx( + "h-9 rounded-lg bg-page px-3 text-sm shadow-input dark:bg-raised", + focusRing, + )} + /> +
+
+ + {busy ? "Creating…" : "Create API key"} + + setCreating(false)}> + Cancel + +
+
+ )} + + {shownKey && ( +
+
+

Your new API key

+

+ Copy it now. convt stores only a hash, so it will not be shown again. +

+
+
+ e.target.select()} + className={cx( + "h-9 w-full min-w-0 rounded-lg bg-page px-3 font-mono text-xs shadow-input dark:bg-raised", + focusRing, + )} + /> + +
+ setShownKey(null)}> + I saved the key + +
+ )} + + {api.keys.length === 0 ? ( +
+

No keys yet.

+

+ {!onSale + ? "Keys open when the API goes on sale." + : enrolled + ? "Create one to start converting." + : "Add a card under API billing, then create a key here."} +

+
+ ) : ( +
    + + {api.keys.map((key) => ( +
  • + {key.name} + + + {key.maskedKey} + + + + Created + {formatDate(key.created)} + + + Last used + {key.lastUsed} + + + {confirming === key.id ? ( + + revoke(key.id)} + aria-label={`Revoke now: ${key.name}`} + className="font-medium" + > + Revoke now + + setConfirming(null)} + aria-label={`Keep ${key.name}`} + > + Keep + + + ) : ( + setConfirming(key.id)} + aria-label={`Revoke ${key.name}`} + > + Revoke + + )} + +
  • + ))} +
+ )} + {confirming && ( +

+ Requests with a revoked key fail at once with 403. This can't be undone. +

+ )} +
+
+ + + + + +
+ + +
- -
- Keys - {api.keys.length === 0 ? ( - - No keys yet. Create one to start converting. +
-
- Quick start - -
+ +
); } + +function Stat({ label, children }: { label: string; children: ReactNode }) { + return ( + +
{label}
+ {children} +
+ ); +} diff --git a/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx b/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx index 1bbcc40f..4d208dad 100644 --- a/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx +++ b/apps/web/src/routes/_app/_shell/dashboard/api_.convert.tsx @@ -7,13 +7,17 @@ import { type Job, } from "@convt/sdk"; import { createFileRoute, useRouter } from "@tanstack/react-router"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useId, useRef, useState, type ReactNode } from "react"; import { Card, + Meter, PageTitle, PrimaryButton, + PrimaryLink, + SecondaryButton, SecondaryLink, TextButton, + cx, focusRing, } from "#/components/app/ui"; import capabilities from "#/generated/cloud-formats.json"; @@ -24,29 +28,65 @@ export const Route = createFileRoute("/_app/_shell/dashboard/api_/convert")({ loader: () => fetchCloudAccess(), component: Converter, }); + +const MAX_BYTES = 2_000_000_000; + +type Stage = "idle" | "uploading" | "queued" | "running" | "succeeded" | "failed" | "cancelled"; +const steps = [ + { stage: "uploading", label: "Upload" }, + { stage: "queued", label: "Queue" }, + { stage: "running", label: "Convert" }, + { stage: "succeeded", label: "Ready" }, +] as const; + +const gb = (bytes: number) => `${(bytes / 1e9).toFixed(2)} GB`; +function size(bytes: number) { + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(1)} MB`; + return `${Math.max(1, Math.round(bytes / 1e3))} KB`; +} +const detect = (file: File | null) => + formats.find((f) => + (f.extensions as readonly string[]).includes(file?.name.split(".").pop()?.toLowerCase() ?? ""), + ); +const targetsFor = (id: string | undefined) => + (capabilities.formats.find((f) => f.id === id)?.targets ?? []) as Format[]; + function Converter() { const access = Route.useLoaderData(); const router = useRouter(); const [file, setFile] = useState(null); - const [target, setTarget] = useState("pdf"); - const [stage, setStage] = useState("idle"); + const [target, setTarget] = useState(null); + const [stage, setStage] = useState("idle"); const [job, setJob] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(""); const [pendingCancel, setPendingCancel] = useState(null); + const [dragging, setDragging] = useState(false); const controller = useRef(null); + const fileInput = useRef(null); useEffect(() => () => controller.current?.abort(), []); - const inputFormat = formats.find((f) => - (f.extensions as readonly string[]).includes(file?.name.split(".").pop()?.toLowerCase() ?? ""), - ); + const inputFormat = detect(file); + const targets = targetsFor(inputFormat?.id); const busy = stage === "uploading" || stage === "queued" || stage === "running"; + + function choose(next: File | null) { + setFile(next); + const reachable = targetsFor(detect(next)?.id); + setTarget((current) => (current && reachable.includes(current) ? current : null)); + setResult(null); + setError(""); + setJob(null); + setStage("idle"); + } + async function convert() { - if (!file || !inputFormat) return; + if (!file || !inputFormat || !target) return; setError(""); setPendingCancel(null); setResult(null); setJob(null); - if (file.size > 2_000_000_000) { + if (file.size > MAX_BYTES) { setError("This file exceeds the 2 GB limit. Use the desktop app for larger files."); return; } @@ -74,14 +114,15 @@ function Converter() { signal: controller.current.signal, onProgress: (j) => { setJob(j); - setStage(j.status); + setStage(j.status === "created" || j.status === "uploaded" ? "uploading" : j.status); }, }); setResult(converted); setStage("succeeded"); await router.invalidate(); } catch (e) { - setStage("failed"); + const cancelled = controller.current?.signal.aborted; + setStage(cancelled ? "cancelled" : "failed"); if (e instanceof ConvtCancellationError) setPendingCancel(e.jobId); setError(e instanceof Error ? e.message : "Conversion failed. Try again."); await router.invalidate(); @@ -96,6 +137,7 @@ function Converter() { if (terminal.status === "succeeded") { const outputs = await client.download(pendingCancel); setResult(new Conversion(terminal, outputs.outputs)); + setStage("succeeded"); setError("Conversion completed before cancellation. Your allowance was settled."); } else if (terminal.status === "cancelled" || terminal.status === "failed") { setError("Conversion cancelled. Your allowance was released."); @@ -106,172 +148,387 @@ function Converter() { setError(e instanceof Error ? e.message : "Retry cancellation shortly."); } } + function reset() { + choose(null); + if (fileInput.current) fileInput.current.value = ""; + } + + const remaining = Math.max(0, access.limit - access.used - access.reserved); + return (
-
- Cloud converter -

- Convert from your browser or phone with Pro. Files are deleted after 24 hours. -

-
- - API keys and usage - - - - {(access.used / 1e9).toFixed(2)} GB - of 50 GB used this month + +
+ Cloud converter +

+ Convert from your browser or phone with Pro. Your file is uploaded to convt cloud storage + for this conversion and deleted after 24 hours. The desktop app converts without uploading + anything. +

+
+ + +
+

+ {gb(access.used)} + of 50 GB used this month +

+

+ {access.reserved > 0 ? `${gb(access.reserved)} reserved · ` : ""}2 GB per file +

+
+
+ {!access.allowed ? ( - -

Cloud conversion needs paid Pro

-

- {access.state === "trialing" - ? "Cloud conversions start after your trial becomes a paid subscription." - : "An active Pro subscription includes 50 GB of input each month."} -

- - View Pro billing - -
+ + View Pro billing + + } + > + {access.state === "trialing" + ? "Cloud conversions start after your trial becomes a paid subscription." + : "An active Pro subscription includes 50 GB of input each month."} + ) : !access.configured ? ( - - Cloud conversion is being connected. You can use the desktop app now. - + + You can use the desktop app now; it converts on your machine. + ) : ( - -
-