mirror of
https://github.com/notion-enhancer/notion-enhancer.git
synced 2025-04-04 12:49:03 +00:00
feat(panel): register/render custom views to side panel
+ fix: debounce useState callback triggers to prevent over-handling conflicts + minor refactoring
This commit is contained in:
parent
608b8d42d4
commit
463607c6d2
@ -123,13 +123,18 @@ const insertPanel = async (api, db) => {
|
||||
const notionFrame = ".notion-frame",
|
||||
notionTopbarBtn = ".notion-topbar-more-button",
|
||||
togglePanelHotkey = await db.get("togglePanelHotkey"),
|
||||
{ html } = api;
|
||||
{ html, setState, addPanelView } = api;
|
||||
|
||||
const $panel = html`<${Panel}
|
||||
_getWidth=${() => db.get("sidePanelWidth")}
|
||||
_setWidth=${(width) => db.set("sidePanelWidth", width)}
|
||||
_getOpen=${() => db.get("sidePanelOpen")}
|
||||
_setOpen=${(open) => db.set("sidePanelOpen", open)}
|
||||
...${Object.assign(
|
||||
...["Width", "Open", "View"].map((key) => ({
|
||||
[`_get${key}`]: () => db.get(`sidePanel${key}`),
|
||||
[`_set${key}`]: async (value) => {
|
||||
await db.set(`sidePanel${key}`, value);
|
||||
setState({ rerender: true });
|
||||
},
|
||||
}))
|
||||
)}
|
||||
/>`,
|
||||
togglePanel = () => {
|
||||
if ($panel.hasAttribute("open")) $panel.close();
|
||||
|
@ -7,9 +7,73 @@
|
||||
import { Tooltip } from "./Tooltip.mjs";
|
||||
import { Select } from "../menu/islands/Select.mjs";
|
||||
|
||||
function View(props) {
|
||||
const { html } = globalThis.__enhancerApi;
|
||||
return html``;
|
||||
// note: these islands do not accept extensible
|
||||
// properties, i.e. they are not reusable.
|
||||
// please register your own interfaces via
|
||||
// globalThis.__enhancerApi.addPanelView and
|
||||
// not by re-instantiating additional panels
|
||||
|
||||
let panelViews = [],
|
||||
// "$icon" may either be an actual dom element,
|
||||
// or an icon name from the lucide icons set
|
||||
addPanelView = ({ title, $icon, $view }) => {
|
||||
panelViews.push([{ title, $icon }, $view]);
|
||||
panelViews.sort(([{ title: a }], [{ title: b }]) => a.localeCompare(b));
|
||||
const { setState } = globalThis.__enhancerApi;
|
||||
setState?.({ panelViews });
|
||||
},
|
||||
removePanelView = ($view) => {
|
||||
panelViews = panelViews.filter(([, v]) => v !== $view);
|
||||
const { setState } = globalThis.__enhancerApi;
|
||||
setState?.({ panelViews });
|
||||
};
|
||||
|
||||
function View({ _get }) {
|
||||
const { html, setState, useState } = globalThis.__enhancerApi,
|
||||
$container = html`<div
|
||||
class="overflow-(y-auto x-hidden)
|
||||
h-full min-w-[var(--panel--width)]"
|
||||
></div>`;
|
||||
useState(["rerender"], async () => {
|
||||
const openView = await _get?.(),
|
||||
$view =
|
||||
panelViews.find(([{ title }]) => {
|
||||
return title === openView;
|
||||
})?.[1] || panelViews[0]?.[1];
|
||||
if (!$container.contains($view)) {
|
||||
$container.innerHTML = "";
|
||||
$container.append($view);
|
||||
}
|
||||
});
|
||||
return $container;
|
||||
}
|
||||
|
||||
function Switcher({ _get, _set, minWidth, maxWidth }) {
|
||||
const { html, extendProps, setState, useState } = globalThis.__enhancerApi,
|
||||
$switcher = html`<div
|
||||
class="relative flex items-center
|
||||
font-medium p-[8.5px] ml-[4px] grow"
|
||||
></div>`,
|
||||
setView = (view) => {
|
||||
_set?.(view);
|
||||
setState({ activePanelView: view });
|
||||
};
|
||||
useState(["panelViews"], ([panelViews]) => {
|
||||
const values = panelViews.map(([{ title, $icon }]) => {
|
||||
// panel switcher internally uses the select island,
|
||||
// which expects an option value rather than a title
|
||||
return { value: title, $icon };
|
||||
});
|
||||
$switcher.innerHTML = "";
|
||||
$switcher.append(html`<${Select}
|
||||
popupMode="dropdown"
|
||||
class="w-full text-left"
|
||||
maxWidth=${maxWidth - 56}
|
||||
minWidth=${minWidth - 56}
|
||||
...${{ _get, _set: setView, values }}
|
||||
/>`);
|
||||
});
|
||||
return $switcher;
|
||||
}
|
||||
|
||||
function Panel({
|
||||
@ -17,91 +81,77 @@ function Panel({
|
||||
_setWidth,
|
||||
_getOpen,
|
||||
_setOpen,
|
||||
_getView,
|
||||
_setView,
|
||||
minWidth = 250,
|
||||
maxWidth = 640,
|
||||
transitionDuration = 300,
|
||||
...props
|
||||
}) {
|
||||
const { html, extendProps, setState, useState } = globalThis.__enhancerApi,
|
||||
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi;
|
||||
extendProps(props, {
|
||||
class: `notion-enhancer--panel order-2 shrink-0
|
||||
transition-[width] open:w-[var(--panel--width)]
|
||||
border-l-1 border-[color:var(--theme--fg-border)]
|
||||
relative bg-[color:var(--theme--bg-primary)] w-0
|
||||
duration-[${transitionDuration}ms] group/panel`,
|
||||
});
|
||||
const { html, setState, useState } = globalThis.__enhancerApi,
|
||||
{ addMutationListener, removeMutationListener } = globalThis.__enhancerApi,
|
||||
$panel = html`<aside
|
||||
class="notion-enhancer--panel relative order-2
|
||||
shrink-0 bg-[color:var(--theme--bg-primary)]
|
||||
border-(l-1 [color:var(--theme--fg-border)])
|
||||
transition-[width] w-[var(--panel--width,0)]
|
||||
duration-[${transitionDuration}ms] group/panel"
|
||||
>
|
||||
<style>
|
||||
.notion-frame {
|
||||
flex-direction: row !important;
|
||||
}
|
||||
.notion-frame [role="progressbar"] {
|
||||
padding-right: var(--panel--width);
|
||||
}
|
||||
.notion-frame [role="progressbar"] > div {
|
||||
overflow-x: clip;
|
||||
}
|
||||
</style>
|
||||
<div
|
||||
class="flex justify-between items-center
|
||||
border-(b [color:var(--theme--fg-border)])"
|
||||
>
|
||||
<${Switcher}
|
||||
...${{ _get: _getView, _set: _setView, minWidth, maxWidth }}
|
||||
/>
|
||||
<button
|
||||
aria-label="Close side panel"
|
||||
class="user-select-none h-[24px] w-[24px] duration-[20ms]
|
||||
transition inline-flex items-center justify-center mr-[10px]
|
||||
rounded-[3px] hover:bg-[color:var(--theme--bg-hover)]"
|
||||
onclick=${() => $panel.close()}
|
||||
>
|
||||
<i
|
||||
class="i-chevrons-right w-[20px] h-[20px]
|
||||
text-[color:var(--theme--fg-secondary)]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<${View} ...${{ _get: _getView }} />
|
||||
</aside>`;
|
||||
|
||||
const values = [
|
||||
{
|
||||
icon: "type",
|
||||
value: "word counter",
|
||||
},
|
||||
{
|
||||
// prettier-ignore
|
||||
$icon: html`<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24">
|
||||
<circle cx="5" cy="7" r="2.8"/>
|
||||
<circle cx="5" cy="17" r="2.79"/>
|
||||
<path d="M21,5.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,5.5,21.55,5.95,21,5.95z"/>
|
||||
<path d="M17,10.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,9.6,17.55,10.05,17,10.05z"/>
|
||||
<path d="M21,15.95H11c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h10c0.55,0,1,0.45,1,1v0C22,15.5,21.55,15.95,21,15.95z" />
|
||||
<path d="M17,20.05h-6c-0.55,0-1-0.45-1-1v0c0-0.55,0.45-1,1-1h6c0.55,0,1,0.45,1,1v0C18,19.6,17.55,20.05,17,20.05z"/>
|
||||
</svg>`,
|
||||
value: "outliner",
|
||||
},
|
||||
],
|
||||
_get = () => useState(["panelView"])[0],
|
||||
_set = (value) => {
|
||||
setState({ panelView: value, rerender: true });
|
||||
};
|
||||
|
||||
const $resize = html`<div
|
||||
let preDragWidth,
|
||||
dragStartX = 0;
|
||||
const $resizeHandle = html`<div
|
||||
class="absolute h-full w-[3px] left-[-3px]
|
||||
z-10 transition duration-300 hover:(cursor-col-resize
|
||||
shadow-[var(--theme--fg-border)_-2px_0px_0px_0px_inset])
|
||||
active:cursor-text group-not-[open]/panel:hidden"
|
||||
></div>`,
|
||||
$close = html`<button
|
||||
aria-label="Close side panel"
|
||||
class="user-select-none h-[24px] w-[24px] duration-[20ms]
|
||||
transition inline-flex items-center justify-center mr-[10px]
|
||||
rounded-[3px] hover:bg-[color:var(--theme--bg-hover)]"
|
||||
>
|
||||
<i
|
||||
class="i-chevrons-right w-[20px] h-[20px]
|
||||
text-[color:var(--theme--fg-secondary)]"
|
||||
/>
|
||||
</div>`,
|
||||
$switcher = html`<div
|
||||
class="relative flex items-center
|
||||
font-medium p-[8.5px] ml-[4px] grow"
|
||||
>
|
||||
<${Select}
|
||||
popupMode="dropdown"
|
||||
class="w-full text-left"
|
||||
maxWidth=${maxWidth - 56}
|
||||
minWidth=${minWidth - 56}
|
||||
...${{ _get, _set, values }}
|
||||
/>
|
||||
</div>`,
|
||||
$view = html`<div
|
||||
class="overflow-(y-auto x-hidden)
|
||||
h-full min-w-[var(--panel--width)]"
|
||||
></div>`,
|
||||
$panel = html`<aside ...${props}>
|
||||
${$resize}
|
||||
<div
|
||||
class="flex justify-between items-center
|
||||
border-(b [color:var(--theme--fg-border)])"
|
||||
>
|
||||
${$switcher}${$close}
|
||||
</div>
|
||||
${$view}
|
||||
</aside>`;
|
||||
|
||||
let preDragWidth,
|
||||
dragStartX = 0;
|
||||
const startDrag = async (event) => {
|
||||
$resizeTooltip = html`<${Tooltip}>
|
||||
<span>Drag</span> to resize<br />
|
||||
<span>Click</span> to closed
|
||||
<//>`,
|
||||
showTooltip = (event) => {
|
||||
setTimeout(() => {
|
||||
const panelOpen = $panel.hasAttribute("open"),
|
||||
handleHovered = $resizeHandle.matches(":hover");
|
||||
if (!panelOpen || !handleHovered) return;
|
||||
const { x } = $resizeHandle.getBoundingClientRect();
|
||||
$resizeTooltip.show(x, event.clientY);
|
||||
}, 200);
|
||||
},
|
||||
startDrag = async (event) => {
|
||||
dragStartX = event.clientX;
|
||||
preDragWidth = await _getWidth?.();
|
||||
if (isNaN(preDragWidth)) preDragWidth = minWidth;
|
||||
@ -121,32 +171,18 @@ function Panel({
|
||||
// trigger panel close if not resized
|
||||
if (dragStartX - event.clientX === 0) $panel.close();
|
||||
};
|
||||
$resize.addEventListener("mousedown", startDrag);
|
||||
|
||||
const $tooltip = html`<${Tooltip}>
|
||||
<span>Drag</span> to resize<br />
|
||||
<span>Click</span> to closed
|
||||
<//>`,
|
||||
showTooltip = (event) => {
|
||||
setTimeout(() => {
|
||||
const panelOpen = $panel.hasAttribute("open"),
|
||||
handleHovered = $resize.matches(":hover");
|
||||
if (!panelOpen || !handleHovered) return;
|
||||
const { x } = $resize.getBoundingClientRect();
|
||||
$tooltip.show(x, event.clientY);
|
||||
}, 200);
|
||||
};
|
||||
$resize.addEventListener("mouseover", showTooltip);
|
||||
$resize.addEventListener("mouseout", () => $tooltip.hide());
|
||||
$close.addEventListener("click", () => $panel.close());
|
||||
$resizeHandle.addEventListener("mouseout", $resizeTooltip.hide);
|
||||
$resizeHandle.addEventListener("mousedown", startDrag);
|
||||
$resizeHandle.addEventListener("mouseover", showTooltip);
|
||||
$panel.prepend($resizeHandle);
|
||||
|
||||
// normally would place outside of an island, but in
|
||||
// this case is necessary for syncing up animations
|
||||
const notionHelp = ".notion-help-button",
|
||||
repositionHelp = async () => {
|
||||
repositionHelp = async (width) => {
|
||||
const $notionHelp = document.querySelector(notionHelp);
|
||||
if (!$notionHelp) return;
|
||||
let width = await _getWidth?.();
|
||||
width ??= await _getWidth?.();
|
||||
if (isNaN(width)) width = minWidth;
|
||||
if (!$panel.hasAttribute("open")) width = 0;
|
||||
const position = $notionHelp.style.getPropertyValue("right"),
|
||||
@ -163,15 +199,17 @@ function Panel({
|
||||
addMutationListener(notionHelp, repositionHelp);
|
||||
|
||||
$panel.resize = async (width) => {
|
||||
$tooltip.hide();
|
||||
$resizeTooltip.hide();
|
||||
if (width) {
|
||||
width = Math.max(width, minWidth);
|
||||
width = Math.min(width, maxWidth);
|
||||
_setWidth?.(width);
|
||||
} else width = await _getWidth?.();
|
||||
if (isNaN(width)) width = minWidth;
|
||||
$panel.style.setProperty("--panel--width", `${width}px`);
|
||||
repositionHelp();
|
||||
if (!$panel.hasAttribute("open")) width = 0;
|
||||
const $cssVarTarget = $panel.parentElement || $panel;
|
||||
$cssVarTarget.style.setProperty("--panel--width", `${width}px`);
|
||||
repositionHelp(width);
|
||||
};
|
||||
$panel.open = () => {
|
||||
$panel.setAttribute("open", true);
|
||||
@ -182,13 +220,13 @@ function Panel({
|
||||
$panel.resize();
|
||||
};
|
||||
$panel.close = () => {
|
||||
$tooltip.hide();
|
||||
$resizeTooltip.hide();
|
||||
$panel.onbeforeclose?.();
|
||||
$panel.removeAttribute("open");
|
||||
$panel.style.pointerEvents = "auto";
|
||||
$panel.querySelectorAll("[tabindex]").forEach(($el) => ($el.tabIndex = -1));
|
||||
$panel.resize();
|
||||
setState({ panelOpen: false });
|
||||
repositionHelp();
|
||||
_setOpen(false);
|
||||
setTimeout(() => {
|
||||
$panel.style.pointerEvents = "";
|
||||
@ -198,8 +236,13 @@ function Panel({
|
||||
_getOpen().then((open) => {
|
||||
if (open) $panel.open();
|
||||
});
|
||||
|
||||
return $panel;
|
||||
}
|
||||
|
||||
globalThis.__enhancerApi ??= {};
|
||||
Object.assign(globalThis.__enhancerApi, {
|
||||
addPanelView,
|
||||
removePanelView,
|
||||
});
|
||||
|
||||
export { Panel };
|
||||
|
@ -60,23 +60,25 @@ function Select({
|
||||
></div>`;
|
||||
|
||||
const options = values.map((opt) => {
|
||||
if (typeof opt === "string") opt = { value: opt };
|
||||
if (["string", "number"].includes(typeof opt)) opt = { value: opt };
|
||||
if (!(opt?.$icon instanceof Element)) {
|
||||
if (opt?.icon && typeof opt.icon === "string") {
|
||||
opt.$icon = html`<i class="i-${opt.icon} h-[16px] w-[16px]" />`;
|
||||
if (typeof opt?.$icon === "string") {
|
||||
opt.$icon = html`<i class="i-${opt.$icon} h-[16px] w-[16px]" />`;
|
||||
} else delete opt.$icon;
|
||||
}
|
||||
opt.$option = html`<${Option} ...${{ ...opt, _get, _set }} />`;
|
||||
opt.$selection = html`<div class="inline-flex items-center gap-[6px]">
|
||||
<!-- swap icon/value order for correct display when dir="rtl" -->
|
||||
<span>${opt.value}</span>${opt.$icon?.cloneNode(true) ?? ""}
|
||||
</div>`;
|
||||
return opt;
|
||||
return {
|
||||
...opt,
|
||||
$option: html`<${Option} ...${{ ...opt, _get, _set }} />`,
|
||||
$value: html`<div class="inline-flex items-center gap-[6px]">
|
||||
<!-- swap icon/value order for correct display when dir="rtl" -->
|
||||
<span>${opt.value}</span>${opt.$icon?.cloneNode(true) ?? ""}
|
||||
</div>`,
|
||||
};
|
||||
}),
|
||||
getSelected = async () => {
|
||||
const value = (await _get?.()) ?? $select.innerText,
|
||||
option = options.find((opt) => opt.value === value);
|
||||
if (!option) _set(options[0].value);
|
||||
if (!option) _set?.(options[0].value);
|
||||
return option || options[0];
|
||||
},
|
||||
onKeydown = (event) => {
|
||||
@ -102,9 +104,9 @@ function Select({
|
||||
let _initialValue;
|
||||
useState(["rerender"], async () => {
|
||||
if (!options.length) return;
|
||||
const { value, $selection } = await getSelected();
|
||||
const { value, $value } = await getSelected();
|
||||
$select.innerHTML = "";
|
||||
$select.append($selection);
|
||||
$select.append($value);
|
||||
if (_requireReload) {
|
||||
_initialValue ??= value;
|
||||
if (value !== _initialValue) setState({ databaseUpdated: true });
|
||||
|
@ -6,6 +6,51 @@
|
||||
|
||||
"use strict";
|
||||
|
||||
// convenient util: batch event handling
|
||||
// every ___ ms to avoid over-handling &
|
||||
// any conflicts / perf.issues that may
|
||||
// otherwise result. a wait time of ~200ms
|
||||
// is recommended (the avg. human visual
|
||||
// reaction time is ~180-200ms)
|
||||
const debounce = (callback, wait = 200) => {
|
||||
let timeoutId;
|
||||
return (...args) => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => callback(...args), wait);
|
||||
};
|
||||
};
|
||||
|
||||
// provides basic key/value reactivity:
|
||||
// this is shared between all active mods,
|
||||
// i.e. mods can read and update other mods'
|
||||
// reactive states. this enables interop
|
||||
// between a mod's component islands and
|
||||
// supports inter-mod communication if so
|
||||
// required. caution should be used in
|
||||
// naming keys to avoid conflicts
|
||||
const _state = {},
|
||||
_subscribers = [],
|
||||
setState = (state) => {
|
||||
Object.assign(_state, state);
|
||||
const updates = Object.keys(state);
|
||||
_subscribers
|
||||
.filter(([keys]) => updates.some((key) => keys.includes(key)))
|
||||
.forEach(([keys, callback]) => callback(keys.map((key) => _state[key])));
|
||||
},
|
||||
// useState(["keyA", "keyB"]) => returns [valueA, valueB]
|
||||
// useState(["keyA", "keyB"], callback) => registers callback
|
||||
// to be triggered after each update to either keyA or keyB,
|
||||
// with [valueA, valueB] passed to the callback's first arg
|
||||
useState = (keys, callback) => {
|
||||
const state = keys.map((key) => _state[key]);
|
||||
if (callback) {
|
||||
callback = debounce(callback);
|
||||
_subscribers.push([keys, callback]);
|
||||
callback(state);
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
let documentObserver,
|
||||
mutationListeners = [];
|
||||
const mutationQueue = [],
|
||||
@ -99,20 +144,21 @@ const modifierAliases = [
|
||||
}
|
||||
};
|
||||
document.addEventListener("keyup", (event) => {
|
||||
const keyupListeners = keyListeners.filter(
|
||||
([, , waitForKeyup]) => waitForKeyup
|
||||
);
|
||||
const keyupListeners = keyListeners //
|
||||
.filter(([, , waitForKeyup]) => waitForKeyup);
|
||||
handleKeypress(event, keyupListeners);
|
||||
});
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const keydownListeners = keyListeners.filter(
|
||||
([, , waitForKeyup]) => !waitForKeyup
|
||||
);
|
||||
const keydownListeners = keyListeners //
|
||||
.filter(([, , waitForKeyup]) => !waitForKeyup);
|
||||
handleKeypress(event, keydownListeners);
|
||||
});
|
||||
|
||||
globalThis.__enhancerApi ??= {};
|
||||
Object.assign(globalThis.__enhancerApi, {
|
||||
debounce,
|
||||
setState,
|
||||
useState,
|
||||
addMutationListener,
|
||||
removeMutationListener,
|
||||
addKeyListener,
|
||||
|
@ -560,7 +560,9 @@ const h = (type, props, ...children) => {
|
||||
} else elem.setAttribute(prop, props[prop]);
|
||||
} else elem[prop] = props[prop];
|
||||
}
|
||||
elem.append(...children);
|
||||
if (type === "style") {
|
||||
elem.append(children.join("").replace(/\s+/g, " "));
|
||||
} else elem.append(...children);
|
||||
return elem;
|
||||
},
|
||||
// combines instance-provided element props
|
||||
@ -586,38 +588,8 @@ const h = (type, props, ...children) => {
|
||||
},
|
||||
html = htm.bind(h);
|
||||
|
||||
// provides basic key/value reactivity:
|
||||
// this is shared between all active mods,
|
||||
// i.e. mods can read and update other mods'
|
||||
// reactive states. this enables interop
|
||||
// between a mod's component islands and
|
||||
// supports inter-mod communication if so
|
||||
// required. caution should be used in
|
||||
// naming keys to avoid conflicts
|
||||
const _state = {},
|
||||
_subscribers = [],
|
||||
setState = (state) => {
|
||||
Object.assign(_state, state);
|
||||
const updates = Object.keys(state);
|
||||
_subscribers
|
||||
.filter(([keys]) => updates.some((key) => keys.includes(key)))
|
||||
.forEach(([keys, callback]) => callback(keys.map((key) => _state[key])));
|
||||
},
|
||||
// useState(["keyA", "keyB"]) => returns [valueA, valueB]
|
||||
// useState(["keyA", "keyB"], callback) => registers callback
|
||||
// to be triggered after each update to either keyA or keyB,
|
||||
// with [valueA, valueB] passed to the callback's first arg
|
||||
useState = (keys, callback) => {
|
||||
const state = keys.map((key) => _state[key]);
|
||||
if (callback) _subscribers.push([keys, callback]);
|
||||
callback?.(state);
|
||||
return state;
|
||||
};
|
||||
|
||||
globalThis.__enhancerApi ??= {};
|
||||
Object.assign(globalThis.__enhancerApi, {
|
||||
html,
|
||||
extendProps,
|
||||
setState,
|
||||
useState,
|
||||
});
|
||||
|
@ -65,7 +65,12 @@ const modDatabase = async (id) => {
|
||||
const optionDefaults =
|
||||
(await getMods())
|
||||
.find((mod) => mod.id === id)
|
||||
?.options?.map?.((opt) => [opt.key, opt.value ?? opt.values?.[0]])
|
||||
?.options?.map?.((opt) => {
|
||||
let value = opt.value;
|
||||
value ??= opt.values?.[0]?.value;
|
||||
value ??= opt.values?.[0];
|
||||
return [opt.key, value];
|
||||
})
|
||||
?.filter?.(([, value]) => typeof value !== "undefined") ?? {};
|
||||
return globalThis.__enhancerApi.initDatabase(
|
||||
[await getProfile(), id],
|
||||
|
Loading…
Reference in New Issue
Block a user