fix undo, cleanup func + animated preset, improve styles

main
Kyle Zheng 2024-07-08 23:57:24 -04:00
rodzic a83f3a6325
commit 15bc9f00a6
7 zmienionych plików z 242 dodań i 85 usunięć

Wyświetl plik

@ -11,7 +11,7 @@ export default function App() {
<Router root={(props) => <Suspense>{props.children}</Suspense>}> <Router root={(props) => <Suspense>{props.children}</Suspense>}>
<FileRoutes /> <FileRoutes />
</Router> </Router>
<footer class="text-sm text-center p-4"> <footer class="text-sm text-center px-4 py-8">
made with by{" "} made with by{" "}
<a <a
class="font-semibold hover:text-fore-base/80 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)" class="font-semibold hover:text-fore-base/80 focus-visible:(outline-none ring-2 ring-fore-base ring-offset-2 ring-offset-back-base)"

Wyświetl plik

@ -1,10 +1,10 @@
import { createEffect, createSignal, onMount, Show } from "solid-js"; import { createEffect, createSignal, onMount, Show, untrack } from "solid-js";
import { basicSetup } from "codemirror"; import { basicSetup } from "codemirror";
import { historyKeymap, indentWithTab } from "@codemirror/commands"; import { historyKeymap, indentWithTab } from "@codemirror/commands";
import { javascript } from "@codemirror/lang-javascript"; import { javascript } from "@codemirror/lang-javascript";
import { syntaxHighlighting } from "@codemirror/language"; import { syntaxHighlighting } from "@codemirror/language";
import { Compartment, Transaction } from "@codemirror/state"; import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, type ViewUpdate } from "@codemirror/view"; import { EditorView, keymap, type ViewUpdate } from "@codemirror/view";
import { import {
oneDarkHighlightStyle, oneDarkHighlightStyle,
@ -20,15 +20,17 @@ type Props = {
onSave: (s: string) => void; onSave: (s: string) => void;
initialValue: string; initialValue: string;
error: string | null; error: string | null;
clearError: ()=>void; clearError: () => void;
}; };
const INITIAL_VIM_MODE = false;
export function CodeInput(props: Props) { export function CodeInput(props: Props) {
let parent: HTMLDivElement; let parent: HTMLDivElement;
let view: EditorView; let view: EditorView;
let modeComp = new Compartment(); let modeComp = new Compartment();
const [vimMode, _setVimMode] = createSignal(false); const [vimMode, _setVimMode] = createSignal(INITIAL_VIM_MODE);
const setVimMode = (v: boolean) => { const setVimMode = (v: boolean) => {
_setVimMode(v); _setVimMode(v);
view.dispatch({ view.dispatch({
@ -38,58 +40,65 @@ export function CodeInput(props: Props) {
const [dirty, setDirty] = createSignal(false); const [dirty, setDirty] = createSignal(false);
const extensions = [
modeComp.of(vimMode() ? vim() : []),
basicSetup,
EditorView.lineWrapping,
keymap.of([
indentWithTab,
{
win: "Mod-Shift-z",
// Dirty hack, but undo/redo commands are not exposed
run: historyKeymap[1].run,
},
{
key: "Mod-s",
linux: "Ctrl-s", // untested, but might be necessary
run: (view) => {
props.onSave(view.state.doc.toString());
return true;
},
},
]),
javascript(),
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.updateListener.of(
debounce((u: ViewUpdate) => {
// docChanged (aka changes.empty) doesn't work when debounced
// if (!u.docChanged) return;
const newDirty = u.state.doc.toString() !== props.initialValue;
setDirty(newDirty);
if (!newDirty && props.error) {
props.clearError();
}
}, 300)
),
];
onMount(() => { onMount(() => {
view = new EditorView({ view = new EditorView({
extensions: [ extensions,
modeComp.of(vimMode() ? vim() : []),
basicSetup,
EditorView.lineWrapping,
keymap.of([
indentWithTab,
{
win: "Mod-Shift-z",
// Dirty hack, but undo/redo commands are not exposed
run: historyKeymap[1].run,
},
{
key: "Mod-s",
linux: "Ctrl-s", // untested, but might be necessary
run: (view) => {
props.onSave(view.state.doc.toString());
return true;
},
},
]),
javascript(),
oneDarkTheme,
syntaxHighlighting(oneDarkHighlightStyle),
EditorView.updateListener.of(
debounce((u: ViewUpdate) => {
// docChanged (aka changes.empty) doesn't work when debounced
// if (!u.docChanged) return;
const newDirty = u.state.doc.toString() !== props.initialValue;
setDirty(newDirty);
if (!newDirty && props.error) {
props.clearError()
}
}, 300)
),
],
parent, parent,
}); });
}); });
// Track props.initialValue
createEffect(() => { createEffect(() => {
view.dispatch({ setDirty(false);
changes: {
from: 0, // Saving should not reset editor state (cursor pos etc)
to: view.state.doc.length, if (view.state.doc.toString() === props.initialValue) return;
insert: props.initialValue,
}, view.setState(EditorState.create({ doc: props.initialValue, extensions }));
// This seems to prevent extra undos just fine, much simpler than toggling history extension
annotations: Transaction.addToHistory.of(false), const currVimMode = untrack(vimMode);
}); if (currVimMode !== INITIAL_VIM_MODE) {
view.dispatch({
effects: modeComp.reconfigure(currVimMode ? vim() : []),
});
}
}); });
return ( return (

Wyświetl plik

@ -31,13 +31,19 @@ const ADD_NEW_FUNC_KEY = "Add new function";
const USER_FUNC_KEYS_KEY = "userFuncKeys"; const USER_FUNC_KEYS_KEY = "userFuncKeys";
export function Editor(props: Props) { export function Editor(props: Props) {
const { inputQr, setInputQr, setRenderFunc } = useQrContext(); const {
inputQr,
setInputQr,
setRenderFunc,
renderFuncKey,
setRenderFuncKey,
} = useQrContext();
const [code, setCode] = createSignal(PRESET_FUNCS.Square); const [code, setCode] = createSignal(PRESET_FUNCS.Square);
const [compileError, setCompileError] = createSignal<string | null>(null); const [compileError, setCompileError] = createSignal<string | null>(null);
const [userFuncKeys, setUserFuncKeys] = createStore<string[]>([]); const [userFuncKeys, setUserFuncKeys] = createStore<string[]>([]);
const [funcKey, setFuncKey] = createSignal("Square");
onMount(() => { onMount(() => {
const storedFuncKeys = localStorage.getItem(USER_FUNC_KEYS_KEY); const storedFuncKeys = localStorage.getItem(USER_FUNC_KEYS_KEY);
@ -58,8 +64,8 @@ export function Editor(props: Props) {
setRenderFunc(() => render); setRenderFunc(() => render);
setCompileError(null); setCompileError(null);
if (!PRESET_FUNCS.hasOwnProperty(funcKey())) { if (!PRESET_FUNCS.hasOwnProperty(renderFuncKey())) {
localStorage.setItem(funcKey(), newCode); localStorage.setItem(renderFuncKey(), newCode);
} }
} catch (e) { } catch (e) {
setCompileError(e!.toString()); setCompileError(e!.toString());
@ -76,7 +82,7 @@ export function Editor(props: Props) {
setUserFuncKeys(userFuncKeys.length, key); setUserFuncKeys(userFuncKeys.length, key);
localStorage.setItem(USER_FUNC_KEYS_KEY, userFuncKeys.join(",")); localStorage.setItem(USER_FUNC_KEYS_KEY, userFuncKeys.join(","));
setFuncKey(key); setRenderFuncKey(key);
trySetCode(code); trySetCode(code);
}; };
@ -86,7 +92,7 @@ export function Editor(props: Props) {
placeholder="https://qrcode.kylezhe.ng" placeholder="https://qrcode.kylezhe.ng"
setValue={(s) => setInputQr("text", s)} setValue={(s) => setInputQr("text", s)}
/> />
<Collapsible trigger="Settings" defaultOpen> <Collapsible trigger="Settings">
<div class="flex justify-between"> <div class="flex justify-between">
<div class="text-sm py-2">Encoding mode</div> <div class="text-sm py-2">Encoding mode</div>
<Select <Select
@ -137,7 +143,7 @@ export function Editor(props: Props) {
/> />
</Row> </Row>
</Collapsible> </Collapsible>
<Collapsible trigger="Rendering"> <Collapsible trigger="Rendering" defaultOpen>
<div class="mb-4"> <div class="mb-4">
<div class="text-sm py-2">Render function</div> <div class="text-sm py-2">Render function</div>
<div class="flex gap-2"> <div class="flex gap-2">
@ -152,7 +158,7 @@ export function Editor(props: Props) {
options: [...userFuncKeys, ADD_NEW_FUNC_KEY], options: [...userFuncKeys, ADD_NEW_FUNC_KEY],
}, },
]} ]}
value={funcKey()} value={renderFuncKey()}
setValue={(key) => { setValue={(key) => {
if (key === ADD_NEW_FUNC_KEY) { if (key === ADD_NEW_FUNC_KEY) {
createAndSelectFunc("render function", PRESET_FUNCS.Square); createAndSelectFunc("render function", PRESET_FUNCS.Square);
@ -166,20 +172,20 @@ export function Editor(props: Props) {
storedCode = `Failed to load ${key}`; storedCode = `Failed to load ${key}`;
} }
} }
setFuncKey(key); setRenderFuncKey(key);
trySetCode(storedCode); trySetCode(storedCode);
} }
}} }}
/> />
<Show when={userFuncKeys.includes(funcKey())}> <Show when={userFuncKeys.includes(renderFuncKey())}>
<IconButtonDialog <IconButtonDialog
title={`Rename ${funcKey()}`} title={`Rename ${renderFuncKey()}`}
triggerTitle="Rename" triggerTitle="Rename"
triggerChildren={<Pencil class="w-5 h-5" />} triggerChildren={<Pencil class="w-5 h-5" />}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
{(close) => { {(close) => {
const [rename, setRename] = createSignal(funcKey()); const [rename, setRename] = createSignal(renderFuncKey());
const [duplicate, setDuplicate] = createSignal(false); const [duplicate, setDuplicate] = createSignal(false);
let ref: HTMLInputElement; let ref: HTMLInputElement;
@ -192,7 +198,7 @@ export function Editor(props: Props) {
defaultValue={rename()} defaultValue={rename()}
onChange={setRename} onChange={setRename}
onInput={() => duplicate() && setDuplicate(false)} onInput={() => duplicate() && setDuplicate(false)}
placeholder={funcKey()} placeholder={renderFuncKey()}
/> />
<div class="absolute p-1 text-sm text-red-600"> <div class="absolute p-1 text-sm text-red-600">
<Show when={duplicate()}> <Show when={duplicate()}>
@ -203,7 +209,7 @@ export function Editor(props: Props) {
class="px-3 py-2 float-right mt-4" class="px-3 py-2 float-right mt-4"
// input onChange runs after focus lost, so onMouseDown is too early // input onChange runs after focus lost, so onMouseDown is too early
onClick={() => { onClick={() => {
if (rename() === funcKey()) return close(); if (rename() === renderFuncKey()) return close();
if ( if (
Object.keys(PRESET_FUNCS).includes(rename()) || Object.keys(PRESET_FUNCS).includes(rename()) ||
@ -211,10 +217,10 @@ export function Editor(props: Props) {
) { ) {
setDuplicate(true); setDuplicate(true);
} else { } else {
localStorage.removeItem(funcKey()); localStorage.removeItem(renderFuncKey());
localStorage.setItem(rename(), code()); localStorage.setItem(rename(), code());
setUserFuncKeys( setUserFuncKeys(
userFuncKeys.indexOf(funcKey()), userFuncKeys.indexOf(renderFuncKey()),
rename() rename()
); );
localStorage.setItem( localStorage.setItem(
@ -222,7 +228,7 @@ export function Editor(props: Props) {
userFuncKeys.join(",") userFuncKeys.join(",")
); );
setFuncKey(rename()); setRenderFuncKey(rename());
close(); close();
} }
}} }}
@ -234,7 +240,7 @@ export function Editor(props: Props) {
}} }}
</IconButtonDialog> </IconButtonDialog>
<IconButtonDialog <IconButtonDialog
title={`Delete ${funcKey()}`} title={`Delete ${renderFuncKey()}`}
triggerTitle="Delete" triggerTitle="Delete"
triggerChildren={<Trash2 class="w-5 h-5" />} triggerChildren={<Trash2 class="w-5 h-5" />}
> >
@ -247,10 +253,10 @@ export function Editor(props: Props) {
<FillButton <FillButton
onMouseDown={() => { onMouseDown={() => {
setUserFuncKeys((keys) => setUserFuncKeys((keys) =>
keys.filter((key) => key !== funcKey()) keys.filter((key) => key !== renderFuncKey())
); );
localStorage.removeItem(funcKey()); localStorage.removeItem(renderFuncKey());
setFuncKey("Square"); setRenderFuncKey("Square");
localStorage.setItem( localStorage.setItem(
USER_FUNC_KEYS_KEY, USER_FUNC_KEYS_KEY,
@ -275,10 +281,10 @@ export function Editor(props: Props) {
<CodeInput <CodeInput
initialValue={code()} initialValue={code()}
onSave={(code) => { onSave={(code) => {
if (Object.keys(PRESET_FUNCS).includes(funcKey())){ if (Object.keys(PRESET_FUNCS).includes(renderFuncKey())) {
createAndSelectFunc(funcKey(), code) createAndSelectFunc(renderFuncKey(), code);
} else { } else {
trySetCode(code) trySetCode(code);
} }
}} }}
error={compileError()} error={compileError()}
@ -294,7 +300,6 @@ function Row(props: {
title: string; title: string;
children: JSX.Element; children: JSX.Element;
}) { }) {
// This should be <label/> but clicking selects first button in buttongroup
return ( return (
<div> <div>
<div class="text-sm py-2" title={props.tooltip}> <div class="text-sm py-2" title={props.tooltip}>
@ -393,4 +398,113 @@ for (let y = 0; y < qr.matrixHeight; y++) {
} }
} }
`, `,
Minimal: `// qr, ctx are args
const Module = {
DataOFF: 0,
DataON: 1,
FinderOFF: 2,
FinderON: 3,
AlignmentOFF: 4,
AlignmentON: 5,
TimingOFF: 6,
TimingON: 7,
FormatOFF: 8,
FormatON: 9,
VersionOFF: 10,
VersionON: 11,
Unset: 12,
}
const pixelSize = 12;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
ctx.fillStyle = "rgb(255, 255, 255)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "rgb(0, 0, 0)";
const finderPos = [
[qr.margin.left, qr.margin.top],
[qr.matrixWidth - qr.margin.right - 7, qr.margin.top],
[qr.margin.left, qr.matrixHeight - qr.margin.bottom - 7],
];
for (const [x, y] of finderPos) {
ctx.fillRect((x + 3) * pixelSize, y * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 3) * pixelSize, (y + 6) * pixelSize, pixelSize, pixelSize);
ctx.fillRect(x * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 6) * pixelSize, (y + 3) * pixelSize, pixelSize, pixelSize);
ctx.fillRect((x + 2) * pixelSize, (y + 2) * pixelSize, 3 * pixelSize, 3 * pixelSize);
}
const minSize = pixelSize / 2;
const offset = (pixelSize - minSize) / 2;
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module | 1) === Module.FinderON) {
continue;
}
if (module & 1) {
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, minSize, minSize);
}
}
}
`,
"Lover (Animated)": `// qr, ctx are args
const pixelSize = 10;
ctx.canvas.width = qr.matrixWidth * pixelSize;
ctx.canvas.height = qr.matrixHeight * pixelSize;
const period = 3000; // ms
const amplitude = 0.8; // maxSize - minSize
const minSize = 0.6;
let counter = 0;
let prevTimestamp;
let req;
function frame(timestamp) {
// performance.now() and requestAnimationFrame's timestamp are not consistent together
if (prevTimestamp != null) {
counter += timestamp - prevTimestamp;
}
prevTimestamp = timestamp;
if (counter >= period) {
counter -= period;
}
ctx.fillStyle = "rgb(0, 0, 0)";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
for (let y = 0; y < qr.matrixHeight; y++) {
for (let x = 0; x < qr.matrixWidth; x++) {
const module = qr.matrix[y * qr.matrixWidth + x];
if ((module & 1) === 0) continue;
const xBias = Math.abs(5 - (x % 10));
const biasCounter = counter + (x + y) * (period / 20) + xBias * (period / 10);
const ratio = Math.abs((period / 2) - (biasCounter % period)) / (period / 2);
const size = (ratio * amplitude + minSize) * pixelSize;
const offset = (pixelSize - size) / 2;
ctx.fillStyle = \`rgb(\${100 + ratio * 150}, \${200 + xBias * 10}, 255)\`;
ctx.fillRect(x * pixelSize + offset, y * pixelSize + offset, size, size);
}
}
req = requestAnimationFrame(frame);
}
req = requestAnimationFrame(frame);
return () => cancelAnimationFrame(req);`
}; };

Wyświetl plik

@ -1,6 +1,6 @@
import { QrError } from "fuqr"; import { QrError } from "fuqr";
import { Match, Show, Switch, createEffect, createSignal } from "solid-js"; import { Match, Show, Switch, createEffect, createSignal, untrack } from "solid-js";
import { useQrContext, type OutputQr } from "~/lib/QrContext"; import { useQrContext, type OutputQr } from "~/lib/QrContext";
import { import {
ECL_LABELS, ECL_LABELS,
@ -26,7 +26,7 @@ type Props = {
}; };
export default function QrPreview(props: Props) { export default function QrPreview(props: Props) {
const { inputQr, outputQr } = useQrContext(); const { inputQr, outputQr, renderFuncKey } = useQrContext();
return ( return (
<div class={props.class}> <div class={props.class}>
@ -62,7 +62,7 @@ export default function QrPreview(props: Props) {
* Running the effect in the ref function caused double rendering for future mounts. * Running the effect in the ref function caused double rendering for future mounts.
*/ */
function RenderedQrCode() { function RenderedQrCode() {
const { outputQr: _outputQr, renderFunc } = useQrContext(); const { outputQr: _outputQr, renderFunc, renderFuncKey } = useQrContext();
const outputQr = _outputQr as () => OutputQr; const outputQr = _outputQr as () => OutputQr;
const fullWidth = () => { const fullWidth = () => {
@ -77,18 +77,38 @@ function RenderedQrCode() {
let qrCanvas: HTMLCanvasElement; let qrCanvas: HTMLCanvasElement;
const [runtimeError, setRuntimeError] = createSignal<string | null>(null); const [runtimeError, setRuntimeError] = createSignal<string | null>(null);
const [cleanupError, setCleanupError] = createSignal<string | null>(null);
const [canvasDims, setCanvasDims] = createSignal({ width: 0, height: 0 }); const [canvasDims, setCanvasDims] = createSignal({ width: 0, height: 0 });
let cleanupFunc: void | (() => void);
let cleanupFuncKey = ""
let prevFuncKey = ""
createEffect(() => { createEffect(() => {
try {
if (typeof cleanupFunc === "function") {
cleanupFunc();
}
setCleanupError(null);
} catch (e) {
setCleanupError(e!.toString());
cleanupFuncKey = prevFuncKey
console.error(`${cleanupFuncKey} cleanup:`, e)
}
const ctx = qrCanvas.getContext("2d")!; const ctx = qrCanvas.getContext("2d")!;
ctx.clearRect(0, 0, qrCanvas.width, qrCanvas.height); ctx.clearRect(0, 0, qrCanvas.width, qrCanvas.height);
prevFuncKey = untrack(renderFuncKey)
try { try {
renderFunc()(outputQr(), ctx); cleanupFunc = renderFunc()(outputQr(), ctx);
setRuntimeError(null); setRuntimeError(null);
} catch (e) { } catch (e) {
setRuntimeError(e!.toString()); setRuntimeError(e!.toString());
console.error(`${prevFuncKey} render:`, e)
} }
setCanvasDims({ width: qrCanvas.width, height: qrCanvas.height }); setCanvasDims({ width: qrCanvas.width, height: qrCanvas.height });
}); });
@ -107,6 +127,12 @@ function RenderedQrCode() {
> >
<canvas class="w-full h-full" ref={qrCanvas!}></canvas> <canvas class="w-full h-full" ref={qrCanvas!}></canvas>
</div> </div>
<Show when={cleanupError() != null}>
<div class="text-purple-100 bg-purple-950 px-2 py-1 rounded-md">
<div class="font-bold">{cleanupFuncKey} cleanup</div>
{cleanupError()}
</div>
</Show>
<Show when={runtimeError() != null}> <Show when={runtimeError() != null}>
<div class="text-red-100 bg-red-950 px-2 py-1 rounded-md"> <div class="text-red-100 bg-red-950 px-2 py-1 rounded-md">
{runtimeError()} {runtimeError()}

Wyświetl plik

@ -11,7 +11,7 @@ export default createHandler(() => (
<link rel="icon" href="/favicon.svg" /> <link rel="icon" href="/favicon.svg" />
{assets} {assets}
</head> </head>
<body class="bg-back-base text-fore-base my-8 [--un-default-border-color:fg-subtle]"> <body class="bg-back-base text-fore-base [--un-default-border-color:fg-subtle]">
<div id="app">{children}</div> <div id="app">{children}</div>
{scripts} {scripts}
</body> </body>

Wyświetl plik

@ -61,9 +61,14 @@ export const QrContext = createContext<{
setOutputQr: Setter<OutputQr | QrError>; setOutputQr: Setter<OutputQr | QrError>;
renderFunc: Accessor<RenderFunc>; renderFunc: Accessor<RenderFunc>;
setRenderFunc: Setter<RenderFunc>; setRenderFunc: Setter<RenderFunc>;
renderFuncKey: Accessor<string>;
setRenderFuncKey: Setter<string>;
}>(); }>();
export type RenderFunc = (qr: OutputQr, ctx: CanvasRenderingContext2D) => void; export type RenderFunc = (
qr: OutputQr,
ctx: CanvasRenderingContext2D
) => void | (() => void);
export function QrContextProvider(props: { children: JSX.Element }) { export function QrContextProvider(props: { children: JSX.Element }) {
const [inputQr, setInputQr] = createStore<InputQr>({ const [inputQr, setInputQr] = createStore<InputQr>({
@ -85,6 +90,7 @@ export function QrContextProvider(props: { children: JSX.Element }) {
); );
const [renderFunc, setRenderFunc] = createSignal<RenderFunc>(defaultRender); const [renderFunc, setRenderFunc] = createSignal<RenderFunc>(defaultRender);
const [renderFuncKey, setRenderFuncKey] = createSignal("Square");
createEffect(() => { createEffect(() => {
try { try {
@ -134,6 +140,8 @@ export function QrContextProvider(props: { children: JSX.Element }) {
setOutputQr, setOutputQr,
renderFunc, renderFunc,
setRenderFunc, setRenderFunc,
renderFuncKey,
setRenderFuncKey
}} }}
> >
{props.children} {props.children}

Wyświetl plik

@ -15,10 +15,10 @@ const QrContextProvider = clientOnly(async () => {
export default function Home() { export default function Home() {
return ( return (
<QrContextProvider> <QrContextProvider>
<main class="max-w-screen-2xl mx-auto p-4"> <main class="max-w-screen-2xl mx-auto px-4">
<div class="flex gap-4 flex-wrap"> <div class="flex gap-4 flex-wrap">
<Editor class="flex-1 flex-grow-3 flex flex-col gap-2 p-4" /> <Editor class="flex-1 flex-grow-3 flex flex-col gap-2 px-4 py-8" />
<QrPreview class="flex-1 flex-grow-2 min-w-300px sticky top-0 self-start p-4 flex flex-col gap-4" /> <QrPreview class="flex-1 flex-grow-2 min-w-300px sticky top-0 self-start px-4 py-8 flex flex-col gap-4" />
</div> </div>
</main> </main>
</QrContextProvider> </QrContextProvider>