kopia lustrzana https://github.com/zhengkyl/qrframe
cloudflare + fix mobile text input + loading state
rodzic
a4271a9dd3
commit
7298e0b487
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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": {
|
||||
|
|
627
pnpm-lock.yaml
627
pnpm-lock.yaml
Plik diff jest za duży
Load Diff
|
@ -1,4 +1,4 @@
|
|||
.checkboard {
|
||||
.checkerboard {
|
||||
background-image: repeating-conic-gradient(#ddd 0% 25%, #aaa 25% 50%);
|
||||
background-position: 50%;
|
||||
background-size: 10% 10%;
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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 />
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 Cloudflare’s global network
|
||||
# Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
|
||||
# [ai]
|
||||
# binding = "AI"
|
||||
|
||||
# Bind a D1 database. D1 is Cloudflare’s 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>"
|
Ładowanie…
Reference in New Issue