aboutsummaryrefslogtreecommitdiff
path: root/source/library/GraphiQL.svelte
diff options
context:
space:
mode:
Diffstat (limited to 'source/library/GraphiQL.svelte')
-rw-r--r--source/library/GraphiQL.svelte322
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>