diff options
Diffstat (limited to 'source/library/GraphiQL.svelte')
| -rw-r--r-- | source/library/GraphiQL.svelte | 322 |
1 files changed, 322 insertions, 0 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte new file mode 100644 index 0000000..0a8f01a --- /dev/null +++ b/source/library/GraphiQL.svelte @@ -0,0 +1,322 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { onMount } from "svelte"; + + /*** UTILITY ------------------------------------------ ***/ + + import DocExplorer from "./components/DocExplorer.svelte"; + import Editor from "./components/Editor.svelte"; + import HeadersEditor from "./components/HeadersEditor.svelte"; + import HistoryPanel from "./components/HistoryPanel.svelte"; + import ResultViewer from "./components/ResultViewer.svelte"; + import TabBar from "./components/TabBar.svelte"; + import Toolbar from "./components/Toolbar.svelte"; + import type { Extension } from "@codemirror/state"; + import type { Fetcher } from "./fetcher/types.ts"; + import { HistoryStore } from "./state/history.svelte.ts"; + import { SchemaStore } from "./state/schema.svelte.ts"; + import { SessionStore } from "./state/session.svelte.ts"; + import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; + import type { Storage } from "./state/storage.ts"; + + type Props = { + fetcher: Fetcher; + initialQuery?: string; + namespace?: string; + storage?: Storage; + theme?: Extension; + }; + + let { + fetcher, + initialQuery = "", + namespace = "eol-graphiql", + storage, + theme + }: Props = $props(); + + const resolvedStorage = storage ?? + (typeof globalThis.localStorage !== "undefined" ? + createLocalStorage(namespace) : + createMemoryStorage()); + + const PERSIST_DEBOUNCE_MS = 300; + const history = new HistoryStore(resolvedStorage); + const schema = new SchemaStore(); + const session = new SessionStore(resolvedStorage); + + if (initialQuery && session.active && session.active.query === "") + session.updateQuery(session.active.id, initialQuery); + + let bottomPane = $state<"variables" | "headers">("variables"); + let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false); + let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false); + let running = $state(false); + + $effect(() => { + void session.tabs; + void session.activeId; + + const timer = setTimeout(() => { + session.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + void history.entries; + + const timer = setTimeout(() => { + history.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + resolvedStorage.set("docExplorer", docsOpen); + }); + + $effect(() => { + resolvedStorage.set("historyPanel", historyOpen); + }); + + onMount(() => { + schema.introspect(fetcher); + }); + + async function run() { + if (running) + return; + + running = true; + + try { + const tab = session.active; + const ok = await session.run(fetcher); + + if (ok && tab) { + history.add({ + headers: tab.headers, + operationName: tab.operationName, + query: tab.query, + title: tab.title, + variables: tab.variables + }); + } + } finally { + running = false; + } + } + + function loadHistory(id: string, inNewTab: boolean) { + const entry = history.entries.find((e) => e.id === id); + + if (!entry) + return; + + const seed = { + headers: entry.headers, + operationName: entry.operationName, + query: entry.query, + title: entry.title, + variables: entry.variables + }; + + if (inNewTab) + session.addTab(seed); + else + session.overwriteActive(seed); + } + + function onQueryChange(value: string) { + if (session.active) + session.updateQuery(session.active.id, value); + } + + function onVariablesChange(value: string) { + if (session.active) + session.active.variables = value; + } + + function onHeadersChange(value: string) { + if (session.active) + session.active.headers = value; + } + + function onKeydown(event: KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey; + + if (meta && event.key === "Enter") { + event.preventDefault(); + run(); + } + } + + function onBeforeUnload() { + session.persist(); + } +</script> + +<style lang="scss"> + .graphiql { + width: 100%; height: 100%; + + background-color: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + display: grid; + font-family: var(--graphiql-font, ui-monospace, SFMono-Regular, monospace); + grid-template-rows: auto auto 1fr; + } + + .panes { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; + + &.history-open { + grid-template-columns: 260px 1fr 1fr; + } + + &.docs-open { + grid-template-columns: 1fr 1fr 320px; + } + + &.history-open.docs-open { + grid-template-columns: 260px 1fr 1fr 320px; + } + } + + .left { + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: 2fr auto 1fr; + min-height: 0; + } + + .query { + min-height: 0; + } + + .switcher { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + display: flex; + } + + .switch { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.375rem 0.75rem; + text-transform: uppercase; + + &:not(.active) { + color: var(--graphiql-muted, #858585); + } + + &.active { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .bottom { + min-height: 0; + } + + .right { + min-height: 0; + } + + .status { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } +</style> + +<svelte:window onbeforeunload={onBeforeUnload} onkeydown={onKeydown}/> + +<div class="graphiql"> + <Toolbar + disabled={running || !session.active} + docsAvailable={schema.schema !== null} + {docsOpen} + {historyOpen} + onRun={run} + onSelectOperation={(name) => { + if (session.active) + session.selectOperation(session.active.id, name); + }} + onToggleDocs={() => (docsOpen = !docsOpen)} + onToggleHistory={() => (historyOpen = !historyOpen)} + operationName={session.active?.operationName ?? null} + operations={session.active?.operations ?? []} + {running} + schemaLoading={schema.loading}/> + <TabBar + activeId={session.activeId} + onAdd={() => session.addTab()} + onClose={(id) => session.closeTab(id)} + onRename={(id, title) => session.renameTab(id, title)} + onSelect={(id) => session.selectTab(id)} + tabs={session.tabs}/> + <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}> + {#if historyOpen} + <HistoryPanel + entries={history.entries} + onClear={() => history.clear()} + onFavorite={(id) => history.favorite(id)} + onLoad={loadHistory} + onRemove={(id) => history.remove(id)}/> + {/if} + <div class="left"> + <div class="query"> + <Editor + language="graphql" + onChange={onQueryChange} + schema={schema.sdl} + {theme} + value={session.active?.query ?? ""}/> + </div> + <div class="switcher"> + <button + class="switch" + class:active={bottomPane === "variables"} + onclick={() => (bottomPane = "variables")}>Variables</button> + <button + class="switch" + class:active={bottomPane === "headers"} + onclick={() => (bottomPane = "headers")}>Headers</button> + </div> + <div class="bottom"> + {#if bottomPane === "variables"} + <Editor + language="json" + onChange={onVariablesChange} + {theme} + value={session.active?.variables ?? "{}"}/> + {:else} + <HeadersEditor + onChange={onHeadersChange} + {theme} + value={session.active?.headers ?? "{}"}/> + {/if} + </div> + </div> + <div class="right"> + <ResultViewer {theme} value={session.active?.result ?? ""}/> + </div> + {#if docsOpen && schema.schema} + <DocExplorer schema={schema.schema}/> + {/if} + </div> + {#if schema.error} + <div class="status">Schema error: {schema.error}</div> + {/if} +</div> |