mirror of
https://github.com/ditatompel/xmr-remote-nodes.git
synced 2025-01-08 20:09:43 +00:00
Adding my old frontpage UI to this project
This commit is contained in:
parent
6caec5718d
commit
92acb52aac
25 changed files with 1078 additions and 7 deletions
43
frontend/src/lib/components/Footer.svelte
Normal file
43
frontend/src/lib/components/Footer.svelte
Normal file
|
@ -0,0 +1,43 @@
|
|||
<script>
|
||||
import { IcnGitHub } from '$lib/components/svg';
|
||||
</script>
|
||||
|
||||
<div class="flex w-full items-end border-t border-surface-500/10 bg-surface-50 dark:bg-surface-900">
|
||||
<footer class="w-full">
|
||||
<div
|
||||
class="md:flex-no-wrap container mx-auto flex max-w-screen-xl flex-col flex-wrap px-5 py-10 md:flex-row md:items-center lg:items-start"
|
||||
>
|
||||
<div class="mx-auto w-64 flex-shrink-0 text-center lg:text-left">
|
||||
<a
|
||||
href="/"
|
||||
class="title-font flex items-center justify-center font-medium md:justify-start"
|
||||
>
|
||||
<span class="txt-logo-gradient text-5xl font-bold">XMR Nodes</span>
|
||||
</a>
|
||||
<p class="mt-2">
|
||||
By <a href="https://www.ditatompel.com">ditatompel</a>,
|
||||
<a href="https://github.com/ditatompel/xmr-nodes" target="_blank" rel="noopener"
|
||||
>source code</a
|
||||
>.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<span class="mt-2 inline-flex justify-center sm:ml-auto sm:mt-0 sm:justify-start">
|
||||
<a
|
||||
href="https://github.com/ditatompel/xmr-nodes"
|
||||
target="_blank"
|
||||
class="cursor-pointer text-gray-500 hover:text-gray-700"
|
||||
aria-label="Link to ditatompel's GitHub profile"
|
||||
>
|
||||
<IcnGitHub class="h-5 w-5" />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-surface-500/5">
|
||||
<div class="container mx-auto px-5 py-4">
|
||||
<p class="text-center text-sm">XMR Nodes</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
72
frontend/src/lib/components/navigation/MainNav.svelte
Normal file
72
frontend/src/lib/components/navigation/MainNav.svelte
Normal file
|
@ -0,0 +1,72 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { LightSwitch, getDrawerStore } from '@skeletonlabs/skeleton';
|
||||
|
||||
const drawerStore = getDrawerStore();
|
||||
|
||||
function drawerOpen() {
|
||||
drawerStore.open({});
|
||||
}
|
||||
</script>
|
||||
|
||||
<nav class="fixed w-full z-20 top-0 start-0 bg-surface-100-800-token shadow-2xl">
|
||||
<div class="mx-auto flex max-w-screen-xl flex-wrap items-center justify-between px-4 py-1">
|
||||
<a href="/" class="flex items-center space-x-3 rtl:space-x-reverse" aria-label="ditatompel">
|
||||
<span
|
||||
class="txt-logo-gradient hidden self-center whitespace-nowrap text-2xl font-semibold lg:block"
|
||||
>XMR Nodes</span
|
||||
>
|
||||
</a>
|
||||
<div class="flex items-center space-x-1 md:order-2 md:space-x-0 rtl:space-x-reverse">
|
||||
<LightSwitch />
|
||||
<button
|
||||
class="btn btn-sm mr-4 md:hidden"
|
||||
aria-label="Mobile Drawer Button"
|
||||
on:click={drawerOpen}
|
||||
>
|
||||
<span>
|
||||
<svg viewBox="0 0 100 80" class="fill-token h-4 w-4">
|
||||
<rect width="100" height="20" />
|
||||
<rect y="30" width="100" height="20" />
|
||||
<rect y="60" width="100" height="20" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="hidden w-full items-center justify-between md:order-1 md:flex md:w-auto">
|
||||
<ul
|
||||
class="flex flex-row space-x-1 rounded-lg bg-white p-0 dark:bg-gray-900 rtl:space-x-reverse"
|
||||
>
|
||||
<li>
|
||||
<a
|
||||
href="/"
|
||||
class={$page.url.pathname === '/' ? 'active' : 'nav-link'}
|
||||
aria-current={$page.url.pathname === '/' ? 'page' : undefined}>Home</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/remote-nodes"
|
||||
class={$page.url.pathname.startsWith('/remote-nodes') ? 'active' : 'nav-link'}
|
||||
>Remote Nodes</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/add-node"
|
||||
class={$page.url.pathname.startsWith('/add-node') ? 'active' : 'nav-link'}>Add Node</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<style lang="postcss">
|
||||
.active {
|
||||
@apply block rounded bg-primary-500 p-2 text-black;
|
||||
}
|
||||
.nav-link {
|
||||
@apply block rounded hover:bg-secondary-500 md:p-2 hover:text-black;
|
||||
}
|
||||
</style>
|
28
frontend/src/lib/components/navigation/MobileDrawer.svelte
Normal file
28
frontend/src/lib/components/navigation/MobileDrawer.svelte
Normal file
|
@ -0,0 +1,28 @@
|
|||
<script>
|
||||
import { page } from '$app/stores';
|
||||
import { getDrawerStore } from '@skeletonlabs/skeleton';
|
||||
|
||||
const drawerStore = getDrawerStore();
|
||||
|
||||
function drawerClose() {
|
||||
drawerStore.close();
|
||||
}
|
||||
|
||||
$: classesActive = (/** @type {string} */ href) =>
|
||||
$page.url.pathname.startsWith(href) ? 'bg-primary-500' : '';
|
||||
</script>
|
||||
|
||||
<nav class="list-nav p-4">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/" class={$page.url.pathname === '/' ? 'bg-primary-500' : ''} on:click={drawerClose}
|
||||
>Home</a
|
||||
>
|
||||
</li>
|
||||
<li><a href="/dib" class={classesActive('/dib')} on:click={drawerClose}>Data</a></li>
|
||||
<li><a href="/tools" class={classesActive('/tools')} on:click={drawerClose}>Tools</a></li>
|
||||
<li><a href="/asn" class={classesActive('/asn')} on:click={drawerClose}>ASN</a></li>
|
||||
<li><a href="/proxy" class={classesActive('/proxy')} on:click={drawerClose}>Proxy</a></li>
|
||||
<li><a href="/monero" class={classesActive('/monero')} on:click={drawerClose}>Monero</a></li>
|
||||
</ul>
|
||||
</nav>
|
|
@ -1,3 +1,5 @@
|
|||
export { default as MainNav } from './MainNav.svelte';
|
||||
export { default as MobileDrawer } from './MobileDrawer.svelte';
|
||||
export { default as AdminNav } from './AdminNav.svelte';
|
||||
export { default as AdminSidebar } from './AdminSidebar.svelte';
|
||||
export { default as AdminMobileDrawer } from './AdminMobileDrawer.svelte';
|
||||
|
|
17
frontend/src/lib/utils/arrays.js
Normal file
17
frontend/src/lib/utils/arrays.js
Normal file
|
@ -0,0 +1,17 @@
|
|||
export const getDistinct = (items) => {
|
||||
return Array.from(getCounter(items).keys());
|
||||
};
|
||||
|
||||
export const getDuplicates = (items) => {
|
||||
return Array.from(getCounter(items).entries())
|
||||
.filter(([, count]) => count !== 1)
|
||||
.map(([key]) => key);
|
||||
};
|
||||
|
||||
export const getCounter = (items) => {
|
||||
const result = new Map();
|
||||
items.forEach((item) => {
|
||||
result.set(item, (result.get(item) ?? 0) + 1);
|
||||
});
|
||||
return result;
|
||||
};
|
210
frontend/src/routes/(front)/+layout.svelte
Normal file
210
frontend/src/routes/(front)/+layout.svelte
Normal file
|
@ -0,0 +1,210 @@
|
|||
<script>
|
||||
// import { writable } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import '../../app.css';
|
||||
import {
|
||||
Toast,
|
||||
Modal,
|
||||
Drawer,
|
||||
initializeStores,
|
||||
getToastStore,
|
||||
storePopup // PopUps
|
||||
} from '@skeletonlabs/skeleton';
|
||||
import { dev, browser } from '$app/environment';
|
||||
import { MainNav, MobileDrawer } from '$lib/components/navigation';
|
||||
import Footer from '$lib/components/Footer.svelte';
|
||||
|
||||
initializeStores();
|
||||
|
||||
const toastStore = getToastStore();
|
||||
|
||||
// popups
|
||||
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
|
||||
|
||||
/* prettier-ignore */
|
||||
const metaDefaults = {
|
||||
title: 'Monero is private, decentralized cryptocurrency that keeps your finances confidential and secure.',
|
||||
description: '',
|
||||
keywords: '',
|
||||
image:
|
||||
'https://vcl-og-img.ditatompel.com/' +
|
||||
encodeURIComponent('Monero is private, decentralized cryptocurrency that keeps your finances confidential and secure.') +
|
||||
'.png?md=0'
|
||||
};
|
||||
const meta = {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
keywords: metaDefaults.keywords,
|
||||
image: metaDefaults.image,
|
||||
article: { publishTime: '', modifiedTime: '', author: '' },
|
||||
// Twitter
|
||||
twitter: {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
image: metaDefaults.image
|
||||
}
|
||||
};
|
||||
|
||||
page.subscribe((page) => {
|
||||
// Restore Page Defaults
|
||||
meta.title = metaDefaults.title;
|
||||
meta.description = metaDefaults.description;
|
||||
meta.keywords = metaDefaults.keywords;
|
||||
meta.image = metaDefaults.image;
|
||||
// Restore Twitter Defaults
|
||||
meta.twitter.title = metaDefaults.title;
|
||||
meta.twitter.description = metaDefaults.description;
|
||||
meta.twitter.image = metaDefaults.image;
|
||||
if (typeof page.data.meta === 'object') {
|
||||
meta.title = page.data.meta.title ?? metaDefaults.title;
|
||||
meta.description = page.data.meta.description ?? metaDefaults.description;
|
||||
meta.keywords = page.data.meta.keywords ?? metaDefaults.description;
|
||||
meta.image = page.data.meta.image ?? metaDefaults.image;
|
||||
// Restore Twitter Defaults
|
||||
meta.twitter.title = page.data.meta.title ?? metaDefaults.title;
|
||||
meta.twitter.description = page.data.meta.description ?? metaDefaults.description;
|
||||
meta.twitter.image = page.data.meta.image ?? metaDefaults.description;
|
||||
if (typeof page.data.meta.article === 'object') {
|
||||
meta.article.author = page.data.meta.article.author ?? '';
|
||||
}
|
||||
// if (!dev) {
|
||||
// promotionEnabled.set(page.data.promotionEnabled ?? false);
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
let isLoading = false;
|
||||
|
||||
// progress bar show
|
||||
beforeNavigate(() => (isLoading = true));
|
||||
|
||||
// scroll to top after nafigation and progress bar
|
||||
afterNavigate((/* params */) => {
|
||||
isLoading = false;
|
||||
// scroll to top when navigate
|
||||
// const isNewPage = params.from?.url.pathname !== params.to?.url.pathname;
|
||||
// const elemPage = document.querySelector('#page');
|
||||
// if (isNewPage && elemPage !== null) {
|
||||
// elemPage.scrollTop = 0;
|
||||
// }
|
||||
});
|
||||
|
||||
if (browser) {
|
||||
/* Service Worker */
|
||||
/** @type {any} */
|
||||
let newWorker;
|
||||
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js', {
|
||||
type: dev ? 'module' : 'classic'
|
||||
})
|
||||
.then((reg) => {
|
||||
reg.addEventListener('updatefound', () => {
|
||||
console.log('SW Update found');
|
||||
// An updated service worker has appeared in reg.installing!
|
||||
newWorker = reg.installing;
|
||||
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
// Has service worker state changed?
|
||||
switch (newWorker.state) {
|
||||
case 'installed':
|
||||
// There is a new service worker available, show the notification
|
||||
if (navigator.serviceWorker.controller) {
|
||||
const notifUpdateSw = {
|
||||
message: 'New version avaiable for this site is available.',
|
||||
autohide: false,
|
||||
action: {
|
||||
label: 'Reload',
|
||||
response: () => window.location.reload()
|
||||
}
|
||||
};
|
||||
toastStore.trigger(notifUpdateSw);
|
||||
// localStorage.clear();
|
||||
// sessionStorage.clear();
|
||||
newWorker.postMessage({ action: 'skipWaiting' });
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('error with service worker', err);
|
||||
});
|
||||
|
||||
/** @type {any} */
|
||||
let refreshing;
|
||||
// The event listener that is fired when the service worker updates
|
||||
// Here we reload the page
|
||||
navigator.serviceWorker.addEventListener('controllerchange', function () {
|
||||
if (refreshing) {
|
||||
// console.log('refreshing');
|
||||
return;
|
||||
}
|
||||
// window.location.reload();
|
||||
refreshing = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{meta.title} — xmr.ditatompel.com</title>
|
||||
<!-- Meta Tags -->
|
||||
<meta name="title" content="{meta.title} — ditatompel.com" />
|
||||
<meta name="description" content={meta.description} />
|
||||
<meta name="keywords" content={meta.keywords} />
|
||||
<meta name="theme-color" content="#272b31" />
|
||||
<meta name="author" content="ditatompel" />
|
||||
|
||||
<!-- Open Graph - https://ogp.me/ -->
|
||||
<meta property="og:site_name" content="ditatompel.com" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://www.ditatompel.com{$page.url.pathname}" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<meta property="og:title" content="{meta.title} — ditatompel.com" />
|
||||
<meta property="og:description" content={meta.description} />
|
||||
<meta property="og:image" content={meta.image} />
|
||||
<meta property="og:image:secure_url" content={meta.image} />
|
||||
<meta property="og:image:type" content="image/png" />
|
||||
<meta property="og:image:width" content="2048" />
|
||||
<meta property="og:image:height" content="1170" />
|
||||
|
||||
<!-- Open Graph: Twitter -->
|
||||
<meta name="twitter:card" content="summary" />
|
||||
<meta name="twitter:site" content="@ditatompel" />
|
||||
<meta name="twitter:creator" content="@ditatompel" />
|
||||
<meta name="twitter:title" content="{meta.twitter.title} — ditatompel.com" />
|
||||
<meta name="twitter:description" content={meta.twitter.description} />
|
||||
<meta name="twitter:image" content={meta.twitter.image} />
|
||||
</svelte:head>
|
||||
|
||||
<Modal />
|
||||
<Toast />
|
||||
<Drawer>
|
||||
<h2 class="p-4">Navigation</h2>
|
||||
<hr />
|
||||
<MobileDrawer />
|
||||
<hr />
|
||||
</Drawer>
|
||||
|
||||
<!-- <AppShell slotSidebarLeft="bg-surface-500/5 w-0 lg:w-64"> -->
|
||||
<!-- <svelte:fragment slot="header"> -->
|
||||
<!-- <MainNav /> -->
|
||||
<!-- </svelte:fragment> -->
|
||||
<!-- <slot /> -->
|
||||
<!-- <svelte:fragment slot="pageFooter"> -->
|
||||
<!-- <Footer /> -->
|
||||
<!-- </svelte:fragment> -->
|
||||
<!-- </AppShell> -->
|
||||
|
||||
<MainNav />
|
||||
|
||||
<div class="pt-10 md:pt-12">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<Footer />
|
|
@ -147,7 +147,7 @@
|
|||
<div class="section-container text-token grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<a
|
||||
class="card {currentVariant} card-hover overflow-hidden py-2 text-center"
|
||||
href="?remote-node"
|
||||
href="/remote-nodes"
|
||||
>
|
||||
<h2 class="h2 font-bold">Remote Nodes</h2>
|
||||
<div class="space-y-4 p-4">
|
||||
|
@ -159,7 +159,7 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<a class="card {currentVariant} card-hover overflow-hidden py-2 text-center" href="?add-node">
|
||||
<a class="card {currentVariant} card-hover overflow-hidden py-2 text-center" href="/add-node">
|
||||
<h2 class="h2 font-bold">Add Node</h2>
|
||||
<div class="space-y-4 p-4">
|
||||
<p>
|
27
frontend/src/routes/(front)/add-node/+page.js
Normal file
27
frontend/src/routes/(front)/add-node/+page.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ data }) {
|
||||
/* prettier-ignore */
|
||||
const metaDefaults = {
|
||||
title: 'Add Monero Node',
|
||||
description: 'You can use this page to add known remote node to the system so my bots can monitor it.',
|
||||
keywords: 'monero, monero node, monero public node, monero wallet'
|
||||
};
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
keywords: metaDefaults.keywords,
|
||||
image:
|
||||
'https://vcl-og-img.ditatompel.com/' + encodeURIComponent(metaDefaults.title) + '.png?md=0',
|
||||
// Article
|
||||
article: { publishTime: '', modifiedTime: '', author: '' },
|
||||
// Twitter
|
||||
twitter: {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
image: metaDefaults.image
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
115
frontend/src/routes/(front)/add-node/+page.svelte
Normal file
115
frontend/src/routes/(front)/add-node/+page.svelte
Normal file
|
@ -0,0 +1,115 @@
|
|||
<script>
|
||||
import { applyAction, enhance } from '$app/forms';
|
||||
import { slide } from 'svelte/transition';
|
||||
import { ProgressBar } from '@skeletonlabs/skeleton';
|
||||
/** @type {import('./$types').PageData} */
|
||||
export let data;
|
||||
|
||||
/** @type {import('./$types').ActionData} */
|
||||
export let form;
|
||||
|
||||
let isProcessing = false;
|
||||
|
||||
/** @type {import('./$types').SubmitFunction} */
|
||||
const handleForm = async () => {
|
||||
isProcessing = true;
|
||||
return async ({ result }) => {
|
||||
isProcessing = false;
|
||||
if (result.type === 'success' || result.type === 'redirect') {
|
||||
close();
|
||||
}
|
||||
await applyAction(result);
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<header id="hero" class="hero-gradient py-7">
|
||||
<div class="section-container text-center">
|
||||
<h1 class="h1 pb-2 font-extrabold">{data.meta.title}</h1>
|
||||
<p>{data.meta.description}</p>
|
||||
</div>
|
||||
<div class="mx-auto w-full max-w-3xl px-20">
|
||||
<hr class="!border-primary-400-500-token !border-t-4 !border-double" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="form-add-monero-node">
|
||||
<div class="section-container text-center">
|
||||
<p>Enter your Monero node information below (IPv4 host only):</p>
|
||||
|
||||
<form class="mx-auto w-full max-w-3xl py-2" method="POST" use:enhance={handleForm}>
|
||||
<div class="grid grid-cols-1 gap-4 py-6 md:grid-cols-4">
|
||||
<label class="label">
|
||||
<span>Protocol *</span>
|
||||
<select name="protocol" class="select variant-form-material" disabled={isProcessing}>
|
||||
<option value="http">HTTP / TOR</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="label md:col-span-2">
|
||||
<span>Host / IP *</span>
|
||||
<input
|
||||
class="input variant-form-material"
|
||||
name="host"
|
||||
type="text"
|
||||
placeholder="Eg: node.example.com or 172.16.17.18"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span>Port *</span>
|
||||
<input
|
||||
class="input variant-form-material"
|
||||
name="port"
|
||||
type="number"
|
||||
placeholder="Eg: 18081"
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<button class="variant-filled-success btn" disabled={isProcessing}
|
||||
>{isProcessing ? 'Processing...' : 'Submit'}</button
|
||||
>
|
||||
</form>
|
||||
|
||||
<div class="mx-auto w-full max-w-3xl py-2">
|
||||
{#if !isProcessing}
|
||||
{#if form?.status === 'error'}
|
||||
<div class="alert variant-ghost-error" transition:slide={{ duration: 500 }}>
|
||||
<div class="alert-message">
|
||||
<h3 class="h3">Error!</h3>
|
||||
<p>{form.message}!</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.status === 'ok'}
|
||||
<div class="alert variant-ghost-success" transition:slide={{ duration: 500 }}>
|
||||
<div class="alert-message">
|
||||
<h3 class="h3">Success!</h3>
|
||||
<p>{form.message}!</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<ProgressBar meter="bg-secondary-500" track="bg-secondary-500/30" value={undefined} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Here you can find list of <a class="anchor" href="/remote-nodes">Monero Remote Node</a>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="postcss">
|
||||
.section-container {
|
||||
@apply mx-auto w-full max-w-7xl p-4;
|
||||
}
|
||||
/* Hero Gradient */
|
||||
/* prettier-ignore */
|
||||
.hero-gradient {
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(242, 104, 34, .4) 0px, transparent 50%),
|
||||
radial-gradient(at 98% 1%, rgba(var(--color-warning-900) / 0.33) 0px, transparent 50%);
|
||||
}
|
||||
</style>
|
27
frontend/src/routes/(front)/remote-nodes/+page.js
Normal file
27
frontend/src/routes/(front)/remote-nodes/+page.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
/** @type {import('./$types').PageLoad} */
|
||||
export async function load({ data }) {
|
||||
/* prettier-ignore */
|
||||
const metaDefaults = {
|
||||
title: 'Monero (XMR)',
|
||||
description: 'Monero is private, decentralized cryptocurrency that keeps your finances confidential and secure.',
|
||||
keywords: 'monero,xmr,monero node,xmrnode,cryptocurrency'
|
||||
};
|
||||
|
||||
return {
|
||||
meta: {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
keywords: metaDefaults.keywords,
|
||||
image:
|
||||
'https://vcl-og-img.ditatompel.com/' + encodeURIComponent(metaDefaults.title) + '.png?md=0',
|
||||
// Article
|
||||
article: { publishTime: '', modifiedTime: '', author: '' },
|
||||
// Twitter
|
||||
twitter: {
|
||||
title: metaDefaults.title,
|
||||
description: metaDefaults.description,
|
||||
image: metaDefaults.image
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
275
frontend/src/routes/(front)/remote-nodes/+page.svelte
Normal file
275
frontend/src/routes/(front)/remote-nodes/+page.svelte
Normal file
|
@ -0,0 +1,275 @@
|
|||
<script>
|
||||
export let data;
|
||||
</script>
|
||||
|
||||
<header id="hero" class="hero-gradient py-7">
|
||||
<div class="section-container text-center">
|
||||
<h1 class="h1 pb-2 font-extrabold">{data.meta.title}</h1>
|
||||
<p class="mx-auto max-w-3xl">
|
||||
<strong>Monero remote node</strong> is a device on the internet running the Monero software with
|
||||
full copy of the Monero blockchain that doesn't run on the same local machine where the Monero
|
||||
wallet is located.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mx-auto w-full max-w-3xl px-20">
|
||||
<hr class="!border-primary-400-500-token !border-t-4 !border-double" />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="introduction ">
|
||||
<div class="section-container text-center !max-w-4xl">
|
||||
<p>
|
||||
Remote node can be used by people who, for their own reasons (usually because of hardware
|
||||
requirements, disk space, or technical abilities), cannot/don't want to run their own node and
|
||||
prefer to relay on one publicly available on the Monero network.
|
||||
</p>
|
||||
<p>
|
||||
Using an open node will allow to make a transaction instantaneously, without the need to
|
||||
download the blockchain and sync to the Monero network first, but at the cost of the control
|
||||
over your privacy. the <strong>Monero community suggests to always run your own node</strong> to
|
||||
obtain the maximum possible privacy and to help decentralize the network.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="monero-remote-node">
|
||||
<div class="section-container">
|
||||
<div class="space-y-2 overflow-x-auto">
|
||||
<div class="flex justify-between">
|
||||
<!-- <DtSrRowsPerPage {handler} /> -->
|
||||
</div>
|
||||
|
||||
<!--
|
||||
<table class="table table-hover table-compact w-full table-auto">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host:Port</th>
|
||||
<th><label for="fNettype">Nettype</label></th>
|
||||
<th><label for="fProtocol">Protocol</label></th>
|
||||
<th><label for="fCc">Country</label></th>
|
||||
<th><label for="fStatus">Status</label></th>
|
||||
<th>Est. Fee</th>
|
||||
<DtSrThSort {handler} orderBy="uptime">Uptime</DtSrThSort>
|
||||
<DtSrThSort {handler} orderBy="last_checked">Check</DtSrThSort>
|
||||
</tr>
|
||||
<tr>
|
||||
<DtSrThFilter {handler} filterBy="host" placeholder="Filter Host / IP" />
|
||||
<th>
|
||||
<select
|
||||
id="fNettype"
|
||||
name="fNettype"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterNettype}
|
||||
on:change={() => {
|
||||
handler.filter(filterNettype, 'nettype');
|
||||
handler.invalidate();
|
||||
}}
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
<option value="mainnet">MAINNET</option>
|
||||
<option value="stagenet">STAGENET</option>
|
||||
<option value="testnet">TESTNET</option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<select
|
||||
id="fProtocol"
|
||||
name="fProtocol"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterProtocol}
|
||||
on:change={() => {
|
||||
handler.filter(filterProtocol, 'protocol');
|
||||
handler.invalidate();
|
||||
}}
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
<option value="tor">TOR</option>
|
||||
<option value="http">HTTP</option>
|
||||
<option value="https">HTTPS</option>
|
||||
</select>
|
||||
</th>
|
||||
<th>
|
||||
<select
|
||||
id="fCc"
|
||||
name="fCc"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterCc}
|
||||
on:change={() => {
|
||||
handler.filter(filterCc, 'country');
|
||||
handler.invalidate();
|
||||
}}
|
||||
>
|
||||
<option value="any">Any</option>
|
||||
{#each data.countries as country}
|
||||
{#if country.cc === ''}
|
||||
<option value="UNKNOWN">UNKNOWN ({country.total_nodes})</option>
|
||||
{:else}
|
||||
<option value={country.cc}
|
||||
>{country.name === '' ? country.cc : country.name} ({country.total_nodes})</option
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</th>
|
||||
<th colspan="2">
|
||||
<select
|
||||
id="fStatus"
|
||||
name="fStatus"
|
||||
class="select variant-form-material"
|
||||
bind:value={filterStatus}
|
||||
on:change={() => {
|
||||
handler.filter(filterStatus, 'status');
|
||||
handler.invalidate();
|
||||
}}
|
||||
>
|
||||
<option value={-1}>Any</option>
|
||||
<option value="0">Offline</option>
|
||||
<option value="1">Online</option>
|
||||
</select>
|
||||
</th>
|
||||
<th colspan="2">
|
||||
<label for="fCors" class="flex items-center justify-center space-x-2">
|
||||
<input
|
||||
id="fCors"
|
||||
class="checkbox"
|
||||
type="checkbox"
|
||||
name="fCors"
|
||||
bind:checked={checkboxCors}
|
||||
on:change={() => {
|
||||
handler.filter(checkboxCors === true ? 1 : -1, 'cors');
|
||||
handler.invalidate();
|
||||
}}
|
||||
/>
|
||||
<p>CORS</p>
|
||||
</label>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each $rows as row}
|
||||
<tr>
|
||||
<td
|
||||
><HostPortCell
|
||||
ip={row.ip}
|
||||
is_tor={row.is_tor}
|
||||
hostname={row.hostname}
|
||||
port={row.port}
|
||||
/></td
|
||||
>
|
||||
<td><NetTypeCell nettype={row.nettype} height={row.last_height} /></td>
|
||||
<td><ProtocolCell protocol={row.protocol} cors={row.cors} /></td>
|
||||
<td
|
||||
><CountryCellWithAsn
|
||||
cc={row.cc}
|
||||
country_name={row.country_name}
|
||||
city={row.city}
|
||||
asn={row.asn}
|
||||
asn_name={row.asn_name}
|
||||
/></td
|
||||
>
|
||||
<td
|
||||
><StatusCell
|
||||
is_available={row.is_available}
|
||||
statuses={row.last_check_statuses}
|
||||
/></td
|
||||
>
|
||||
<td
|
||||
><EstimateFeeCell
|
||||
estimate_fee={row.estimate_fee}
|
||||
majority_fee={netFees[row.nettype]}
|
||||
/></td
|
||||
>
|
||||
<td><UptimeCell uptime={row.uptime} /></td>
|
||||
<td>{format(row.last_checked * 1000, 'PP HH:mm')}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="flex justify-between">
|
||||
<DtSrRowCount {handler} />
|
||||
<DtSrPagination {handler} />
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="page-info" class="mx-auto w-full max-w-4xl px-4 pb-7">
|
||||
<div class="alert card shadow-xl">
|
||||
<div class="alert-message">
|
||||
<h2 class="h3">Info</h2>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>
|
||||
Uptime percentage calculated is the <strong>last 1 month</strong> uptime.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Est. Fee</strong> here is just fee estimation / byte from
|
||||
<code class="code text-rose-900 font-bold">get_fee_estimate</code> RPC call method.
|
||||
</li>
|
||||
<li>
|
||||
Malicious actors who running remote nodes <a
|
||||
class="external"
|
||||
href="https://w3-s3-jkt1.ditatompel.com/pub/assets/img/site-contents/monero-tx-fee-node.jpg"
|
||||
target="_blank"
|
||||
rel="noopener">still can return high fee only if you about to create a transactions</a
|
||||
>.
|
||||
</li>
|
||||
<li><strong>The best and safest way is running your own node</strong>!</li>
|
||||
<li>
|
||||
Nodes with 0% uptime within 1 month with more than 300 check attempt will be removed. You
|
||||
can always add your node again latter.
|
||||
</li>
|
||||
<li>
|
||||
You can filter remote node by selecting on <strong>nettype</strong>,
|
||||
<strong>protocol</strong>, <strong>country</strong>,
|
||||
<strong>tor</strong>, and <strong>online status</strong> option.
|
||||
</li>
|
||||
<li>
|
||||
If you know one or more remote node that we don't currently monitor, please add them using <a
|
||||
href="/add-node">this form</a
|
||||
>.
|
||||
</li>
|
||||
<li>
|
||||
I deliberately cut the long Tor addresses, click the <span
|
||||
class="text-orange-800 dark:text-orange-300">👁 torhostname...</span
|
||||
> to see the full Tor address.
|
||||
</li>
|
||||
<li>
|
||||
You can found larger remote nodes database from <a
|
||||
class="external"
|
||||
href="https://monero.fail/"
|
||||
role="button"
|
||||
target="_blank"
|
||||
rel="noopener">monero.fail</a
|
||||
>.
|
||||
</li>
|
||||
<li>
|
||||
If you are developer or power user who like to fetch Monero remote node above in JSON
|
||||
format, you can read <a
|
||||
class="external"
|
||||
href="https://insights.ditatompel.com/en/blog/2022/01/public-api-monero-remote-node-list/"
|
||||
>Public API Monero Remote Node List</a
|
||||
> blog post for more detailed information.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style lang="postcss">
|
||||
.section-container {
|
||||
@apply mx-auto w-full max-w-7xl p-4;
|
||||
}
|
||||
/* Hero Gradient */
|
||||
/* prettier-ignore */
|
||||
.hero-gradient {
|
||||
background-image:
|
||||
radial-gradient(at 0% 0%, rgba(242, 104, 34, .4) 0px, transparent 50%),
|
||||
radial-gradient(at 98% 1%, rgba(var(--color-warning-900) / 0.33) 0px, transparent 50%);
|
||||
}
|
||||
/*
|
||||
td:nth-child(1) {
|
||||
@apply max-w-20;
|
||||
}
|
||||
*/
|
||||
</style>
|
22
frontend/src/routes/(front)/remote-nodes/api-handler.js
Normal file
22
frontend/src/routes/(front)/remote-nodes/api-handler.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { PUBLIC_API_ENDPOINT } from '$env/static/public';
|
||||
|
||||
/** @param {import('@vincjo/datatables/remote/state')} state */
|
||||
export async function loadApiData(state) {
|
||||
const response = await fetch(`${PUBLIC_API_ENDPOINT}/monero/remote-node-dt?${getParams(state)}`);
|
||||
const json = await response.json();
|
||||
|
||||
state.setTotalRows(json.data.total ?? 0);
|
||||
|
||||
return json.data.nodes ?? [];
|
||||
}
|
||||
|
||||
const getParams = ({ pageNumber, offset, rowsPerPage, sort, filters }) => {
|
||||
let params = `page=${pageNumber}&limit=${rowsPerPage}`;
|
||||
if (sort) {
|
||||
params += `&sort=${sort.orderBy}&dir=${sort.direction}`;
|
||||
}
|
||||
if (filters) {
|
||||
params += filters.map(({ filterBy, value }) => `&${filterBy}=${value}`).join('');
|
||||
}
|
||||
return params;
|
||||
};
|
|
@ -0,0 +1,38 @@
|
|||
<script lang="ts">
|
||||
export let cc: string;
|
||||
export let country_name: string;
|
||||
export let city: string;
|
||||
export let asn: number;
|
||||
export let asn_name: string;
|
||||
|
||||
$: lowerCc = cc.toLowerCase();
|
||||
</script>
|
||||
|
||||
{#if cc != ''}
|
||||
{#if city !== ''}
|
||||
{city},
|
||||
{/if}
|
||||
{country_name}
|
||||
<img
|
||||
class="inline-block"
|
||||
src="https://edge.ditatompel.com/assets/img/cf/svg/{lowerCc}.svg"
|
||||
alt="{cc} Flag"
|
||||
width="22px"
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if asn !== 0}
|
||||
<br /><a class="asn" href="/asn/{asn}">AS{asn}</a> (<span class="asn-name">{asn_name}</span>)
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
a {
|
||||
@apply font-semibold text-sky-800 underline dark:text-sky-500;
|
||||
}
|
||||
a.asn {
|
||||
@apply !text-purple-800 dark:!text-purple-400;
|
||||
}
|
||||
span.asn-name {
|
||||
@apply font-semibold text-green-800 dark:text-green-500;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
export let estimate_fee: number;
|
||||
export let majority_fee: number;
|
||||
</script>
|
||||
|
||||
{#if estimate_fee !== majority_fee}
|
||||
<span class="text-orange-800 dark:text-orange-300">{estimate_fee}<br />(CAUTION!)</span>
|
||||
{:else}
|
||||
{estimate_fee}
|
||||
{/if}
|
|
@ -0,0 +1,47 @@
|
|||
<script>
|
||||
import { getModalStore } from '@skeletonlabs/skeleton';
|
||||
|
||||
const modalStore = getModalStore();
|
||||
/** @type {string} */
|
||||
export let ip;
|
||||
/** @type {boolean} */
|
||||
export let is_tor;
|
||||
/** @type {string} */
|
||||
export let hostname;
|
||||
/** @type {number} */
|
||||
export let port;
|
||||
|
||||
// if (is_tor) {
|
||||
// hostname = hostname.substring(0, 8) + '[...].onion';
|
||||
// }
|
||||
|
||||
/**
|
||||
* @param {string} onionAddr
|
||||
* @param {number} port
|
||||
*/
|
||||
function modalAlert(onionAddr, port) {
|
||||
/** @typedef {import('@skeletonlabs/skeleton').ModalSettings} ModalSettings */
|
||||
/** @type {ModalSettings} */
|
||||
const modal = {
|
||||
type: 'alert',
|
||||
title: 'Hostname:',
|
||||
body: '<code class="code">' + onionAddr + ':' + port + '</code>'
|
||||
};
|
||||
modalStore.trigger(modal);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if is_tor}
|
||||
<button
|
||||
class="max-w-32 truncate text-orange-800 dark:text-orange-300"
|
||||
on:click={() => modalAlert(hostname, port)}
|
||||
>
|
||||
👁 {hostname}
|
||||
</button><br />.onion:<span class="text-indigo-800 dark:text-indigo-400">{port}</span>
|
||||
<span class="text-gray-700 dark:text-gray-400">(TOR)</span>
|
||||
{:else}
|
||||
{hostname}:<span class="text-indigo-800 dark:text-indigo-400">{port}</span>
|
||||
{#if ip !== ''}
|
||||
<br /><span class="text-gray-700 dark:text-gray-400">{ip}</span>
|
||||
{/if}
|
||||
{/if}
|
|
@ -0,0 +1,15 @@
|
|||
<script>
|
||||
/** @type {string} */
|
||||
export let nettype;
|
||||
/** @type {number} */
|
||||
export let height;
|
||||
</script>
|
||||
|
||||
{#if nettype === 'stagenet'}
|
||||
<span class="font-semibold uppercase text-sky-800 dark:text-sky-500">{nettype}</span>
|
||||
{:else if nettype === 'testnet'}
|
||||
<span class="font-semibold uppercase text-rose-800 dark:text-rose-400">{nettype}</span>
|
||||
{:else}
|
||||
<span class="font-semibold uppercase text-green-800 dark:text-green-500">{nettype}</span>
|
||||
{/if}
|
||||
<br />{height}
|
|
@ -0,0 +1,16 @@
|
|||
<script>
|
||||
/** @type {string} */
|
||||
export let protocol;
|
||||
/** @type {boolean} */
|
||||
export let cors;
|
||||
</script>
|
||||
|
||||
{#if protocol === 'http'}
|
||||
<span class="font-semibold uppercase text-sky-800 dark:text-sky-500">{protocol}</span>
|
||||
{:else}
|
||||
<span class="font-semibold uppercase text-green-800 dark:text-green-500">{protocol}</span>
|
||||
{/if}
|
||||
|
||||
{#if cors}
|
||||
<br />(CORS 💪)
|
||||
{/if}
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { getDistinct } from '$lib/utils/arrays';
|
||||
|
||||
export let filterValue; //: Writable<string>;
|
||||
export let preFilteredValues; //: Readable<unknown[]>;
|
||||
$: uniqueValues = getDistinct($preFilteredValues);
|
||||
</script>
|
||||
|
||||
<div class="pt-2">
|
||||
<select name="filterAnonymity" class="select" bind:value={$filterValue} on:click|stopPropagation>
|
||||
<option value={undefined}>All</option>
|
||||
{#each uniqueValues as value}
|
||||
{#if value === true}
|
||||
<option {value}>TOR</option>
|
||||
{:else}
|
||||
<option {value}>CLEARNET</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
|
@ -0,0 +1,22 @@
|
|||
<script>
|
||||
import { getDistinct } from '$lib/utils/arrays';
|
||||
|
||||
/** @type {string} */
|
||||
export let filterName;
|
||||
export let filterValue; //: Writable<string>;
|
||||
export let preFilteredValues; //: Readable<unknown[]>;
|
||||
$: uniqueValues = getDistinct($preFilteredValues);
|
||||
</script>
|
||||
|
||||
<div class="pt-2">
|
||||
<select name={filterName} class="select" bind:value={$filterValue} on:click|stopPropagation>
|
||||
<option value={undefined}>All</option>
|
||||
{#each uniqueValues as value}
|
||||
{#if value === ''}
|
||||
<option {value}>UNKNOWN</option>
|
||||
{:else}
|
||||
<option {value}>{value.toUpperCase()}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { getDistinct } from '$lib/utils/arrays';
|
||||
|
||||
export let filterValue; //: Writable<string>;
|
||||
export let preFilteredValues; //: Readable<unknown[]>;
|
||||
$: uniqueValues = getDistinct($preFilteredValues);
|
||||
</script>
|
||||
|
||||
<div class="pt-2">
|
||||
<select name="filterStatus" class="select" bind:value={$filterValue} on:click|stopPropagation>
|
||||
<option value={undefined}>All</option>
|
||||
{#each uniqueValues as value}
|
||||
{#if value === true}
|
||||
<option {value}>ONLINE</option>
|
||||
{:else}
|
||||
<option {value}>OFFLINE</option>
|
||||
{/if}
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
export let is_available: boolean;
|
||||
export let statuses: number[];
|
||||
</script>
|
||||
|
||||
{#if is_available}
|
||||
<span class="font-semibold text-green-800 dark:text-green-500">Online</span>
|
||||
{:else}
|
||||
<span class="text-rose-800 dark:text-rose-400">Offline</span>
|
||||
{/if}
|
||||
<br />
|
||||
{#each statuses as status}
|
||||
{#if status === 1}
|
||||
<span class="text-success-700 dark:text-success-400 mr-1">•</span>
|
||||
{:else if status === 0}
|
||||
<span class="text-error-700 dark:text-error-400 mr-1">•</span>
|
||||
{:else}
|
||||
<span class="text-surface-400 dark:text-surface-600 mr-1">•</span>
|
||||
{/if}
|
||||
{/each}
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
export let uptime: number;
|
||||
</script>
|
||||
|
||||
{#if uptime > 98}
|
||||
<span class="text-green-800 dark:text-green-500">{uptime}%</span>
|
||||
{:else if uptime < 98 && uptime > 80}
|
||||
<span class="text-sky-800 dark:text-sky-500">{uptime}%</span>
|
||||
{:else if uptime < 80 && uptime > 75}
|
||||
<span class="text-orange-800 dark:text-orange-300">{uptime}%</span>
|
||||
{:else}
|
||||
<span class="text-rose-800 dark:text-rose-400">{uptime}%</span>
|
||||
{/if}
|
10
frontend/src/routes/(front)/remote-nodes/components/index.js
Normal file
10
frontend/src/routes/(front)/remote-nodes/components/index.js
Normal file
|
@ -0,0 +1,10 @@
|
|||
export { default as CountryCellWithAsn } from './CountryCellWithAsn.svelte';
|
||||
export { default as EstimateFeeCell } from './EstimateFeeCell.svelte';
|
||||
export { default as HostPortCell } from './HostPortCell.svelte';
|
||||
export { default as NetTypeCell } from './NetTypeCell.svelte';
|
||||
export { default as ProtocolCell } from './ProtocolCell.svelte';
|
||||
export { default as SelectAnonymityFilter } from './SelectAnonymityFilter.svelte';
|
||||
export { default as SelectFilter } from './SelectFilter.svelte';
|
||||
export { default as SelectStatusFilter } from './SelectStatusFilter.svelte';
|
||||
export { default as StatusCell } from './StatusCell.svelte';
|
||||
export { default as UptimeCell } from './UptimeCell.svelte';
|
|
@ -1,20 +1,20 @@
|
|||
<script>
|
||||
// import { base } from '$app/paths';
|
||||
import '../app.css';
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom'
|
||||
import {
|
||||
import { beforeNavigate, afterNavigate } from '$app/navigation';
|
||||
import { computePosition, autoUpdate, offset, shift, flip, arrow } from '@floating-ui/dom';
|
||||
import {
|
||||
ProgressBar,
|
||||
initializeStores,
|
||||
storePopup // PopUps
|
||||
} from '@skeletonlabs/skeleton';
|
||||
|
||||
initializeStores();
|
||||
initializeStores();
|
||||
|
||||
// popups
|
||||
storePopup.set({ computePosition, autoUpdate, offset, shift, flip, arrow });
|
||||
|
||||
let isLoading = false;
|
||||
let isLoading = false;
|
||||
|
||||
// progress bar show
|
||||
beforeNavigate(() => (isLoading = true));
|
||||
|
@ -23,6 +23,7 @@
|
|||
isLoading = false;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if isLoading}
|
||||
<ProgressBar
|
||||
class="fixed top-0 z-50"
|
||||
|
@ -32,3 +33,4 @@
|
|||
/>
|
||||
{/if}
|
||||
<slot />
|
||||
|
||||
|
|
Loading…
Reference in a new issue