cloudflare + fix mobile text input + loading state

main
Kyle Zheng 2024-10-12 09:11:54 -04:00
rodzic a4271a9dd3
commit 7298e0b487
14 zmienionych plików z 982 dodań i 113 usunięć

5
.gitignore vendored
Wyświetl plik

@ -4,7 +4,6 @@ dist
.output
.vercel
.netlify
netlify
.vinxi
# Environment
@ -27,3 +26,7 @@ gitignore
# System Files
.DS_Store
Thumbs.db
# wrangler files
.wrangler
.dev.vars

Wyświetl plik

@ -5,7 +5,10 @@ import wasmpack from "vite-plugin-wasm-pack";
export default defineConfig({
server: {
preset: "vercel",
preset: "cloudflare-pages",
rollupConfig: {
external: ["node:async_hooks"]
}
},
ssr: true,
vite: {

Wyświetl plik

@ -5,7 +5,8 @@
"dev": "vinxi dev",
"build": "vinxi build",
"start": "vinxi start",
"presets": "node updatePresets"
"preview": "pnpm run build && npx wrangler pages dev",
"deploy": "pnpm run build && wrangler pages deploy"
},
"dependencies": {
"@codemirror/commands": "^6.6.0",
@ -29,9 +30,11 @@
"vinxi": "^0.3.11"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20241011.0",
"@unocss/transformer-variant-group": "^0.59.4",
"prettier": "^3.3.3",
"vite-plugin-wasm-pack": "^0.1.12"
"vite-plugin-wasm-pack": "^0.1.12",
"wrangler": "^3.80.4"
},
"pnpm": {
"overrides": {

Plik diff jest za duży Load Diff

Wyświetl plik

@ -1,4 +1,4 @@
.checkboard {
.checkerboard {
background-image: repeating-conic-gradient(#ddd 0% 25%, #aaa 25% 50%);
background-position: 50%;
background-size: 10% 10%;

Wyświetl plik

@ -10,6 +10,7 @@ type Props = {
onPng: (resizeWidth, resizeHeight) => void;
onSvg: () => void;
compact: boolean;
disabled: boolean;
};
export function SplitButton(props: Props) {
const [customWidth, setCustomWidth] = createSignal(1000);
@ -27,14 +28,18 @@ export function SplitButton(props: Props) {
return (
<div class="leading-tight flex flex-1">
<Button
class="border border-e-none rounded-md rounded-e-none hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) inline-flex justify-center items-center gap-1 flex-1 px-3 py-2"
class="border border-e-none rounded-md rounded-e-none hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) inline-flex justify-center items-center gap-1 flex-1 px-3 py-2 disabled:(pointer-events-none opacity-50)"
onClick={() => onPng(0, 0)}
disabled={props.disabled}
>
<Download size={20} />
{props.compact ? "Download" : "PNG"}
</Button>
<Popover gutter={4} open={open()} onOpenChange={setOpen}>
<Popover.Trigger class="group border rounded-md rounded-s-none hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) p-2">
<Popover.Trigger
class="group border rounded-md rounded-s-none hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) p-2 disabled:(pointer-events-none opacity-50)"
disabled={props.disabled}
>
<ChevronDown
size={20}
class="block group-data-[expanded]:rotate-180 transition-transform"

Wyświetl plik

@ -3,6 +3,9 @@ import { debounce } from "~/lib/util";
type TextareaProps = {
setValue: (i: string) => void;
placeholder?: string;
onFocus: () => void;
onBlur: () => void;
ref: HTMLTextAreaElement | ((el: HTMLTextAreaElement) => void);
};
/** No `value` prop b/c textarea cannot be controlled */
@ -13,6 +16,9 @@ export function TextareaInput(props: TextareaProps) {
class="bg-back-subtle min-h-[41.6px] px-3 py-2 rounded-md border focus:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) placeholder:text-fore-subtle"
onInput={(e) => onInput(e.target.value)}
onChange={(e) => props.setValue(e.target.value)}
onFocus={props.onFocus}
onBlur={props.onBlur}
ref={props.ref}
placeholder={props.placeholder}
></textarea>
);
@ -24,7 +30,7 @@ type InputProps = {
onInput: (s: string) => void;
ref?: HTMLInputElement;
class?: string;
onKeyDown?: (e: KeyboardEvent) => void
onKeyDown?: (e: KeyboardEvent) => void;
};
/** UNCONTROLLED */

Wyświetl plik

@ -25,6 +25,9 @@ import "virtual:blob-rewriter";
type Props = {
class?: string;
onTextFocus: () => void;
onTextBlur: () => void;
textRef: (ref: HTMLTextAreaElement) => void;
};
const FUNC_KEYS = "funcKeys";
@ -288,6 +291,9 @@ export function Editor(props: Props) {
<TextareaInput
placeholder="https://qrframe.kylezhe.ng"
setValue={(s) => setInputQr("text", s || "https://qrframe.kylezhe.ng")}
onFocus={props.onTextFocus}
onBlur={props.onTextBlur}
ref={props.textRef}
/>
<Collapsible trigger="Data">
<Settings />

Wyświetl plik

@ -1,9 +1,8 @@
import { QrError } from "fuqr";
import Download from "lucide-solid/icons/download";
import Share2 from "lucide-solid/icons/share-2";
import Info from "lucide-solid/icons/info";
import { Match, Show, Switch, type JSX } from "solid-js";
import { useQrContext, type OutputQr } from "~/lib/QrContext";
import { Match, onCleanup, Show, Switch, type JSX } from "solid-js";
import { QrState, useQrContext } from "~/lib/QrContext";
import {
ECL_LABELS,
ECL_NAMES,
@ -20,48 +19,73 @@ import { Popover } from "@kobalte/core/popover";
type Props = {
classList: JSX.CustomAttributes<HTMLDivElement>["classList"];
compact: boolean;
ref: HTMLDivElement;
};
export function QrPreview(props: Props) {
const { inputQr, outputQr } = useQrContext();
const { inputQr, output } = useQrContext();
return (
<div classList={props.classList}>
<Show
when={typeof outputQr() !== "number"}
fallback={
<div class="aspect-[1/1] border rounded-md p-2">
<Switch>
<Match when={outputQr() === QrError.ExceedsMaxCapacity}>
Data exceeds max capacity
</Match>
<Match when={outputQr() === QrError.InvalidEncoding}>
{`Input cannot be encoded in ${
// @ts-expect-error props.mode not null b/c InvalidEncoding requires mode
MODE_NAMES[inputQr.mode + 1]
} mode`}
</Match>
</Switch>
</div>
}
>
<div classList={{ "max-w-[300px] w-full self-center": props.compact }}>
<div classList={props.classList} ref={props.ref}>
<div classList={{ "max-w-[300px] w-full self-center": props.compact }}>
<Show
when={output().state === QrState.Ready}
fallback={
<div class="checkerboard aspect-[1/1] border rounded-md p-2 text-black">
<Switch>
<Match when={output().state === QrState.Loading}>
<svg
viewBox="-12 -12 48 48"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z">
<animateTransform
attributeName="transform"
type="rotate"
dur="0.75s"
values="0 12 12;360 12 12"
repeatCount="indefinite"
/>
</path>
</svg>
</Match>
<Match when={output().state === QrState.ExceedsMaxCapacity}>
Data exceeds max capacity
</Match>
<Match when={output().state === QrState.InvalidEncoding}>
{`Input cannot be encoded in ${
// @ts-expect-error props.mode not null b/c InvalidEncoding requires mode
MODE_NAMES[inputQr.mode + 1]
} mode`}
</Match>
</Switch>
</div>
}
>
<RenderedQrCode />
</div>
<DownloadButtons title={!props.compact} compact={props.compact} />
<Show when={!props.compact}>
<Metadata />
</Show>
</div>
<DownloadButtons title={!props.compact} compact={props.compact} />
<Show when={!props.compact}>
<Metadata />
</Show>
</div>
);
}
function RenderedQrCode() {
const { render, error, addSvgParentRef, addCanvasRef } = useRenderContext();
const { render, error, svgParentRefs, addSvgParentRef, canvasRefs, addCanvasRef } = useRenderContext();
let i = svgParentRefs.length
let j = canvasRefs.length
onCleanup(() => {
svgParentRefs.splice(i, 1)
canvasRefs.splice(j, 1)
})
return (
<>
<div class="checkboard aspect-[1/1] border rounded-md grid [&>*]:[grid-area:1/1] overflow-hidden">
<div class="checkerboard aspect-[1/1] border rounded-md grid [&>*]:[grid-area:1/1] overflow-hidden">
<div
classList={{
hidden: render()?.type !== "svg",
@ -84,34 +108,40 @@ function RenderedQrCode() {
}
function Metadata() {
const { outputQr } = useQrContext() as { outputQr: () => OutputQr };
const { output } = useQrContext();
return (
<div>
<div class="font-bold text-sm pb-2">QR Metadata</div>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="">
Version
<div class="font-bold text-base">
{outputQr().version} ({outputQr().version * 4 + 17}x
{outputQr().version * 4 + 17} matrix)
<Show when={output().state === QrState.Ready}>
<div class="grid grid-cols-2 gap-2 text-sm">
<div class="">
Version
<div class="font-bold text-base">
{output().qr!.version} ({output().qr!.version * 4 + 17}x
{output().qr!.version * 4 + 17} matrix)
</div>
</div>
<div class="">
Error tolerance{" "}
<div class="font-bold text-base whitespace-pre">
{ECL_NAMES[output().qr!.ecl]} ({ECL_LABELS[output().qr!.ecl]})
</div>
</div>
<div class="">
Mask{" "}
<span class="font-bold text-base">
{MASK_KEY[output().qr!.mask]}
</span>
</div>
<div class="">
Encoding{" "}
<span class="font-bold text-base">
{MODE_KEY[output().qr!.mode]}
</span>
</div>
</div>
<div class="">
Error tolerance{" "}
<div class="font-bold text-base whitespace-pre">
{ECL_NAMES[outputQr().ecl]} ({ECL_LABELS[outputQr().ecl]})
</div>
</div>
<div class="">
Mask{" "}
<span class="font-bold text-base">{MASK_KEY[outputQr().mask]}</span>
</div>
<div class="">
Encoding{" "}
<span class="font-bold text-base">{MODE_KEY[outputQr().mode]}</span>
</div>
</div>
</Show>
</div>
);
}
@ -122,14 +152,14 @@ type DownloadProps = {
};
function DownloadButtons(props: DownloadProps) {
const { outputQr } = useQrContext() as { outputQr: () => OutputQr };
const { output } = useQrContext();
const { render, svgParentRefs, canvasRefs } = useRenderContext();
const filename = () => outputQr().text.slice(0, 32);
const filename = () => output().qr!.text.slice(0, 32);
const pngBlob = async (resizeWidth, resizeHeight) => {
// 10px per module assuming 2 module margin
const minWidth = (outputQr().version * 4 + 17 + 4) * 10;
const minWidth = (output().qr!.version * 4 + 17 + 4) * 10;
let outCanvas: HTMLCanvasElement;
if (render()?.type === "canvas") {
@ -187,6 +217,8 @@ function DownloadButtons(props: DownloadProps) {
URL.revokeObjectURL(url);
};
const disabled = () => output().state !== QrState.Ready;
return (
<div>
<Show when={props.title}>
@ -194,6 +226,7 @@ function DownloadButtons(props: DownloadProps) {
</Show>
<div class={props.compact ? "flex gap-2" : "grid grid-cols-2 gap-2"}>
<SplitButton
disabled={disabled()}
compact={props.compact}
onPng={async (resizeWidth, resizeHeight) => {
try {
@ -213,6 +246,7 @@ function DownloadButtons(props: DownloadProps) {
<Show when={!props.compact && render()?.type === "svg"}>
<FlatButton
class="flex-1 inline-flex justify-center items-center gap-1 px-3 py-2"
disabled={disabled()}
onClick={downloadSvg}
>
<Download size={20} />
@ -222,6 +256,7 @@ function DownloadButtons(props: DownloadProps) {
<Show when={props.compact}>
<FlatButton
class="inline-flex justify-center items-center gap-1 px-6 py-2"
disabled={disabled()}
title="Share"
onClick={async () => {
let blob;
@ -259,7 +294,8 @@ function DownloadButtons(props: DownloadProps) {
<Show when={props.compact}>
<Popover gutter={4}>
<Popover.Trigger
class="border rounded-md hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) p-2"
class="border rounded-md hover:bg-fore-base/5 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base) p-2 disabled:(pointer-events-none opacity-50)"
disabled={disabled()}
title="QR Metadata"
>
<Info size={20} />

Wyświetl plik

@ -1,12 +1,22 @@
import {
createContext,
createMemo,
createSignal,
useContext,
type Accessor,
type JSX,
} from "solid-js";
import { ECL, Mode, Mask, QrError, QrOptions, Version, generate } from "fuqr";
import init, {
ECL,
Mode,
Mask,
QrError,
QrOptions,
Version,
generate,
} from "fuqr";
import { createStore, type SetStoreFunction } from "solid-js/store";
import { isServer } from "solid-js/web";
type InputQr = {
text: string;
@ -27,10 +37,37 @@ export type OutputQr = Readonly<{
matrix: Uint8Array;
}>;
type Output =
| {
state: QrState.Ready;
qr: Readonly<{
text: string;
version: number;
ecl: ECL;
mode: Mode;
mask: Mask;
matrix: Uint8Array;
}>;
}
| {
state:
| QrState.Loading
| QrState.InvalidEncoding
| QrState.ExceedsMaxCapacity;
qr: null;
};
export enum QrState {
InvalidEncoding = QrError.InvalidEncoding,
ExceedsMaxCapacity = QrError.ExceedsMaxCapacity,
Loading = 2,
Ready = 3,
}
export const QrContext = createContext<{
inputQr: InputQr;
setInputQr: SetStoreFunction<InputQr>;
outputQr: Accessor<OutputQr | QrError>;
output: Accessor<Output>;
}>();
export function QrContextProvider(props: { children: JSX.Element }) {
@ -44,12 +81,24 @@ export function QrContextProvider(props: { children: JSX.Element }) {
mask: null,
});
const outputQr = createMemo(() => {
// can't skip first render, b/c need to track deps
const [initDone, setInitDone] = createSignal(false);
if (!isServer) {
init().then(() => {
setInitDone(true);
});
}
const output = createMemo(() => {
if (!initDone()) {
return {
state: QrState.Loading,
qr: null,
};
}
try {
// NOTE: WASM ptrs (QrOptions, Version) become null after leaving scope
// They can't be reused or stored
const qrOptions = new QrOptions()
.min_version(new Version(inputQr.minVersion))
.strict_version(inputQr.strictVersion)
@ -59,11 +108,17 @@ export function QrContextProvider(props: { children: JSX.Element }) {
.mode(inputQr.mode!); // null instead of undefined (wasm-pack type)
return {
text: inputQr.text,
...generate(inputQr.text, qrOptions),
state: QrState.Ready,
qr: {
text: inputQr.text,
...generate(inputQr.text, qrOptions),
},
};
} catch (e) {
return e as QrError;
return {
state: e as QrState,
qr: null,
};
}
});
@ -72,7 +127,7 @@ export function QrContextProvider(props: { children: JSX.Element }) {
value={{
inputQr,
setInputQr,
outputQr,
output,
}}
>
{props.children}

Wyświetl plik

@ -10,7 +10,7 @@ import {
import { createStore, unwrap, type SetStoreFunction } from "solid-js/store";
import { type Params, type ParamsSchema } from "./params";
import { clearToasts, toastError } from "~/components/ErrorToasts";
import { useQrContext, type OutputQr } from "./QrContext";
import { QrState, useQrContext, type OutputQr } from "./QrContext";
export const RenderContext = createContext<{
render: Accessor<Render | null>;
@ -46,7 +46,7 @@ type Render = {
};
export function RenderContextProvider(props: { children: JSX.Element }) {
const { outputQr } = useQrContext();
const { output } = useQrContext();
const [renderKey, setRenderKey] = createSignal<string>("Square");
const [render, setRender] = createSignal<Render | null>(null);
@ -82,6 +82,7 @@ export function RenderContextProvider(props: { children: JSX.Element }) {
// I could expose multiple versions of the set functions
// but that seems much less maintainable that this
createEffect(async () => {
if (output().state !== QrState.Ready) return
const r = render();
// Track store without leaking extra params
@ -119,7 +120,7 @@ export function RenderContextProvider(props: { children: JSX.Element }) {
worker!.postMessage({
type: r.type,
url: r.url,
qr: outputQr(),
qr: output().qr,
params: paramsCopy,
timeoutId,
});

Wyświetl plik

@ -1,21 +1,23 @@
import { clientOnly } from "@solidjs/start";
import { isServer, Portal } from "solid-js/web";
import init from "fuqr";
import { Editor } from "~/components/editor/QrEditor";
import { ErrorToasts } from "~/components/ErrorToasts";
import { QrPreview } from "~/components/preview/QrPreview";
import { RenderContextProvider } from "~/lib/RenderContext";
import { createSignal, onCleanup } from "solid-js";
const QrContextProvider = clientOnly(async () => {
await init();
return {
default: (await import("../lib/QrContext")).QrContextProvider,
};
});
import { QrContextProvider } from "~/lib/QrContext";
export default function Home() {
return (
<QrContextProvider>
<RenderContextProvider>
<Temp />
</RenderContextProvider>
</QrContextProvider>
);
}
function Temp() {
// tracking mediaquery in js b/c rendering step draws to all mounted elements
let desktop;
if (isServer) {
@ -25,33 +27,79 @@ export default function Home() {
const [matches, setMatches] = createSignal(mql.matches);
const callback = (e) => setMatches(e.matches);
mql.addEventListener("change", callback);
desktop = matches;
const viewport = window.visualViewport!;
let prevHeight = viewport.height;
const detectMobileKeyboard = () => {
const prev = prevHeight;
prevHeight = viewport.height;
if (desktop() || !textFocused()) return;
if (viewport.height === prev) return;
if (viewport.height > prev) {
// closing mobile keyboard
textRef.blur();
}
};
viewport.addEventListener("resize", detectMobileKeyboard);
onCleanup(() => {
mql.removeEventListener("change", callback);
window.removeEventListener("resize", detectMobileKeyboard);
});
desktop = matches;
}
let qrPreview: HTMLDivElement;
let textRef: HTMLTextAreaElement;
const [textFocused, setTextFocused] = createSignal(false);
const onFocus = () => {
setTextFocused(true);
};
const onBlur = () => {
if (!desktop()) {
// firefox scrolls input behind sticky QrPreview
// adding/removing sticky + this scroll + animation
// gives roughly equivalent ux as chrome default
const before = `${qrPreview.getBoundingClientRect().top}px`;
qrPreview.animate([{ top: before }, { top: 0 }], {
// slow animation to prevent bounce
duration: 1000,
easing: "ease-out",
});
window.scroll({
top: 0,
left: 0,
behavior: "smooth",
});
}
setTextFocused(false);
};
return (
<QrContextProvider>
<RenderContextProvider>
<main class="max-w-screen-2xl mx-auto">
<div class="flex flex-col-reverse md:flex-row">
<Editor class="flex-1 flex-grow-3 flex flex-col gap-2 px-4 py-4 md:py-8" />
<QrPreview
classList={{
"flex flex-col gap-4 px-4": true,
"sticky top-0 py-4 rounded-b-[1rem] border-b shadow-2xl bg-back-base z-10":
!desktop(),
"flex-1 flex-grow-2 min-w-300px self-start py-8": desktop(),
}}
compact={!desktop()}
/>
</div>
<Portal>
<ErrorToasts />
</Portal>
</main>
</RenderContextProvider>
</QrContextProvider>
<main class="max-w-screen-2xl mx-auto">
<div class="flex flex-col-reverse md:flex-row">
<Editor
class="flex-1 flex-grow-3 flex flex-col gap-2 px-4 py-4 md:py-8"
onTextFocus={onFocus}
onTextBlur={onBlur}
textRef={(ref) => (textRef = ref)}
/>
<QrPreview
ref={qrPreview!}
classList={{
"top-0 flex flex-col gap-4 px-4": true,
"py-4 rounded-b-[1rem] border-b shadow-2xl bg-back-base z-10 [transition:top]":
!desktop(),
sticky: !desktop() && !textFocused(),
"sticky flex-1 flex-grow-2 min-w-300px self-start py-8": desktop(),
}}
compact={!desktop()}
/>
</div>
<Portal>
<ErrorToasts />
</Portal>
</main>
);
}

Wyświetl plik

@ -1,7 +1,7 @@
import { defineConfig } from "unocss";
import transformerVariantGroup from "@unocss/transformer-variant-group";
export default defineConfig({
blocklist: ["m55"],
blocklist: ["m55", "resize"],
transformers: [transformerVariantGroup()],
theme: {
colors: {

84
wrangler.toml 100644
Wyświetl plik

@ -0,0 +1,84 @@
#:schema node_modules/wrangler/config-schema.json
name = "qrframe"
compatibility_date = "2024-10-11"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "./dist"
# Automatically place your workloads in an optimal location to minimize latency.
# If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
# rather than the end user may result in better performance.
# Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
# [placement]
# mode = "smart"
# Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
# Note: Use secrets to store sensitive data.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
# [vars]
# MY_VARIABLE = "production_value"
# Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflares global network
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
# [ai]
# binding = "AI"
# Bind a D1 database. D1 is Cloudflares native serverless SQL database.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
# [[d1_databases]]
# binding = "MY_DB"
# database_name = "my-database"
# database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
# Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
# Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
# Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
# [[durable_objects.bindings]]
# name = "MY_DURABLE_OBJECT"
# class_name = "MyDurableObject"
# script_name = 'my-durable-object'
# Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
# [[kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
# Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
# [[queues.producers]]
# binding = "MY_QUEUE"
# queue = "my-queue"
# Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
# [[r2_buckets]]
# binding = "MY_BUCKET"
# bucket_name = "my-bucket"
# Bind another Worker service. Use this binding to call another Worker without network overhead.
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
# [[services]]
# binding = "MY_SERVICE"
# service = "my-service"
# To use different bindings for preview and production environments, follow the examples below.
# When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
# Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
######## PREVIEW environment config ########
# [env.preview.vars]
# API_KEY = "xyz789"
# [[env.preview.kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "<PREVIEW_NAMESPACE_ID>"
######## PRODUCTION environment config ########
# [env.production.vars]
# API_KEY = "abc123"
# [[env.production.kv_namespaces]]
# binding = "MY_KV_NAMESPACE"
# id = "<PRODUCTION_NAMESPACE_ID>"