Profile
Back to NewsBack
GitHub Trending 9 min
Reader Mode
Blockstream/esplora: Explorer for Bitcoin and Liquid

Blockstream/esplora: Explorer for Bitcoin and Liquid

20 hours ago

Esplora Block Explorer

build status</a> docker release</a> MIT license</a> Pull Requests Welcome</a> IRC</a>

Block explorer web interface based on the esplora-electrs HTTP API.

Written as a single-page app in a reactive and functional style using rxjs and cycle.js.

See live at Blockstream.info.

API documentation is available here.

Join the translation efforts on Transifex.

!Esplora

Features

  • Explore blocks, transactions and addresses
  • Support for Segwit and Bech32 addresses
  • Shows previous output and spending transaction details
  • Quick-search for txid, address, block hash or height by navigating to /
  • Advanced view with script hex/assembly, witness data, outpoints and more
  • Mobile-ready responsive design
  • Translated to 17 languages
  • Noscript support
  • For Liquid and other Elements-based chains: support for CT, peg-in/out transactions and multi-asset
  • Mainnet, Testnet and Elements high performance electrum server

Developing

To start a development server with live babel/browserify transpilation, run:

$ git clone https://github.com/Blockstream/esplora && cd esplora
$ npm install
$ export API_URL=http://localhost:3000/ # or https://blockstream.info/api/ if you don't have a local API server

(see more config options below)

$ npm run dev-server

The server will be available at

To display debugging information for the Rx streams in the web developer console, set localStorage.debug = '*' and refresh.

Client polling

Browser polling uses three shared cadence tiers defined in client/src/const.js:

  • Fast (30 seconds): chain tip, pending block templates, and recent mempool
transactions shown on the dashboard and recent-transactions page.
  • Standard (60 seconds): mempool summary, fee estimates, and pending peg
transactions.
  • Slow (10 minutes): the Bitcoin market chart, the Liquid high-value-assets
panel, and a block-list safety poll used to detect same-height reorganizations.

View-scoped polls run only while their view is active and the browser window is focused. Their first tick occurs after a full interval because route entry already requests the initial data. This avoids sending duplicate requests when opening a page. The chain tip is global and also refreshes immediately when the application starts or a block, transaction, or address page is opened.

The block list refreshes when the chain tip height changes, with the slow safety poll covering same-height reorganizations. Confirmed peg state and peg chain transactions also refresh on new blocks rather than on a timer. A newly observed tip resets pending block template polling and delays the next request by 15 seconds so the electrs cache can refresh.

The dashboard requests both /mempool/recent and /mempool because they serve different UI contracts. /mempool/recent supplies the recent transaction list; /mempool supplies aggregate backlog fields such as transaction count, virtual size, total fees, and the fee histogram used by congestion and transaction fee analysis.

Building

To build the static assets directory for production deployment, set config options (see below) and run $ npm run dist. The files will be created under dist/.

Because Esplora is a single-page app, the HTTP server needs to be configured to serve the index.html file in reply to missing pages. See contrib/nginx.conf.in for example nginx configuration (TL;DR: try_files $uri /index.html).

Pre-rendering server (noscript)

To start a pre-rendering server that generates static HTML replies suitable for noscript users, run:

# (clone, cd, "npm install" and configure as above)

$ export STATIC_ROOT=http://localhost:5000/ # for loading CSS, images and fonts $ npm run prerender-server

The server will be available at

Configuration options

All options are optional.

GUI options

  • NODE_ENV - set to production to enable js minification, or to development to disable (defaults to production)
  • BASE_HREF - base href for user interface (defaults to /, change if not served from the root directory)
  • BASE_PREFIX - optional path prefix prepended to each flavor's BASE_HREF, to serve the whole multi-network site under a sub-directory (e.g. BASE_PREFIX=preview serves /preview/, /preview/signet/, ...). API_URL is left un-prefixed so a prefixed build still uses the same backend
  • STATIC_ROOT - root for static assets (defaults to BASE_HREF, change to load static assets from a different server)
  • API_URL - URL for HTTP REST API (defaults to /api, change if the API is available elsewhere)
  • CANONICAL_URL - absolute base url for user interface (optional, only required for opensearch and canonical link tags)
  • NATIVE_ASSET_LABEL - the name of the network native asset (defaults to BTC)
  • TARGET_BLOCK_INTERVAL_SECONDS - expected time between blocks, used for confirmation ETAs (defaults to 60 for Elements chains and 600 otherwise)
  • SITE_TITLE - website title for </code> (defaults to <code>Block Explorer</code>)</li> <li><code>SITE_DESC</code> - meta description (defaults to <code>Esplora Block Explorer</code>)</li> <li><code>HOME_TITLE</code> - text for homepage title (defaults to <code>SITE_TITLE</code>)</li> <li><code>SITE_FOOTER</code> - text for page footer (defaults to <code>Powered by esplora</code>)</li> <li><code>HEAD_HTML</code> - custom html to inject at the end of <code><head></code></li> <li><code>FOOT_HTML</code> - custom html to inject at the end of <code><body></code></li> <li><code>CUSTOM_ASSETS</code> - space separated list of static assets to add to the build</li> <li><code>CUSTOM_CSS</code> - space separated list of css files to append into <code>style.css</code></li> <li><code>NOSCRIPT_REDIR</code> - redirect noscript users to <code>{request_path}?nojs</code> (should be captured server-side and redirected to the prerender server, also see <code>NOSCRIPT_REDIR_BASE</code> in dev server options)</li> <li><code>NAVBAR_HTML</code> - display navigation bar</li></ul> Note that <code>API_URL</code> should be set to the publicly-reachable URL where the user's browser can issue requests at. (that is, <em>not</em> via <code>localhost</code>, unless you're setting up a dev environment where the browser is running on the same machine as the API server.) <p>Elements-only configuration:</p> <ul><li><code>IS_ELEMENTS</code> - set to <code>1</code> to indicate this is an Elements-based chain (enables asset issuance and Elements-specific features)</li> <li><code>SHOW_HIGH_VALUE_ASSETS</code> - set to <code>1</code> to show circulating values for selected assets on the Liquid mainnet dashboard (enabled by the Liquid mainnet flavor)</li> <li><code>NATIVE_ASSET_ID</code> - the ID of the native asset used to pay fees (defaults to <code>6f0279e9ed041c3d710a9f57d0c02928416460c4b722ae3457a11eec381c526d</code>, the asset id for BTC)</li> <li><code>BLIND_PREFIX</code> - the base58 address prefix byte used for confidential addresses (defaults to <code>12</code>)</li> <li><code>PARENT_CHAIN_EXPLORER_TXOUT</code> - URL format for linking to transaction outputs on the parent chain, with <code>{txid}</code> and <code>{vout}</code> as placeholders. Example: <code>https://blockstream.info/tx/{txid}#output:{vout}</code></li> <li><code>PARENT_CHAIN_EXPLORER_ADDRESS</code> - URL format for linking to addresses on parent chain, with <code>{addr}</code> replaced by the address. Example: <code>https://blockstream.info/address/{addr}</code></li> <li><code>ASSET_MAP_URL</code> - url to load json asset map (in the "minimal" format)</li></ul> Menu configuration (useful for inter-linking multiple instances on different networks): <ul><li><code>MENU_ITEMS</code> - json map of menu items, where the key is the label and the value is the url</li> <li><code>MENU_ACTIVE</code> - the active menu item identified by its label</li></ul> <h3>Development server options</h3> <p>All GUI options, plus:</p> <ul><li><code>PORT</code> - port to bind http development server (defaults to <code>5000</code>)</li> <li><code>CORS_ALLOW</code> - value to set for <code>Access-Control-Allow-Origin</code> header (optional)</li> <li><code>NOSCRIPT_REDIR_BASE</code> - base url for prerender server, for redirecting <code>?nojs</code> requests (should be set alongside <code>NOSCRIPT_REDIR</code>)</li></ul> <h3>Pre-rendering server options</h3> <p>All GUI options, plus:</p> <ul><li><code>PORT</code> - port to bind pre-rendering server (defaults to <code>5001</code>)</li></ul> Note that unlike the regular JavaScript-based app that sends API requests from the client-side, the pre-rendering server sends API requests from the server-side. This means that <code>API_URL</code> should be configured to the URL reachable by the server, typically <code>http://localhost:3000/</code>. <h2>How to build the Docker image</h2> <p>Build the base image first:</p> <pre><code class="bash">docker build -t blockstream/esplora-base -f contrib/Dockerfile.base .</code></pre> <p>then the main image:</p> <pre><code class="bash">docker build -t esplora -f contrib/Dockerfile .</code></pre> <p>Alternatively, you may use the pre-built <a href="https://hub.docker.com/r/blockstream/esplora" target="_blank" rel="noopener"><code>blockstream/esplora</code> image</a> from Docker Hub. The <code>latest</code> tag tracks the <code>master</code> branch; release tags (e.g. <code>esplora_v2.10</code>) are also published as versioned image tags (e.g. <code>blockstream/esplora:v2.10</code>) that can be pinned for reproducible deployments.</p> <h2>How to run the explorer for Bitcoin mainnet</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8080:80 \ --volume $PWD/data_bitcoin_mainnet:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh bitcoin-mainnet explorer"</code></pre> <h2>How to run the explorer for Liquid mainnet</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8082:80 \ --volume $PWD/data_liquid_mainnet:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh liquid-mainnet explorer"</code></pre> <h2>How to run the explorer for Bitcoin testnet3</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8084:80 \ --volume $PWD/data_bitcoin_testnet:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh bitcoin-testnet explorer"</code></pre> <h2>How to run the explorer for Bitcoin testnet4</h2> <pre><code class="">docker run -p 50001:50001 -p 8084:80 \ --volume $PWD/data_bitcoin_testnet4:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh bitcoin-testnet4 explorer"</code></pre> <h2>How to run the explorer for Bitcoin signet</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8084:80 \ --volume $PWD/data_bitcoin_signet:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh bitcoin-signet explorer"</code></pre> <h2>How to run the explorer for Liquid testnet</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8096:80 \ --volume $PWD/data_liquid_testnet:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh liquid-testnet explorer"</code></pre> <h2>How to run the explorer for Liquid regtest</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8092:80 \ --volume $PWD/data_liquid_regtest:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh liquid-regtest explorer"</code></pre> <h2>How to run the explorer for Bitcoin regtest</h2> <pre><code class="bash">docker run -p 50001:50001 -p 8094:80 \ --volume $PWD/data_bitcoin_regtest:/data \ --rm -i -t esplora \ bash -c "/srv/explorer/run.sh bitcoin-regtest explorer"</code></pre> <h2>Regtest options</h2> <p>When run for Bitcoin regtest or Liquid regtest, the esplora container will create a default wallet and mine 100 blocks internally. You can disable this behavior by setting <code>NO_REGTEST_MINING=1</code>.</p> <h2>Docker config options</h2> <p>Set <code>-e DEBUG=verbose</code> to enable more verbose logging.</p> <p>Set <code>-e NO_PRECACHE=1</code> to disable pre-caching of statistics for "popular addresses", which may take a long time and is not necessary for personal use.</p> <p>Set <code>-e NO_ADDRESS_SEARCH=1</code> to disable the <a href="https://github.com/Blockstream/esplora/blob/master/API.md#get-address-prefixprefix" target="_blank" rel="noopener">by-prefix address search</a> index.</p> <p>Set <code>-e ENABLE_LIGHTMODE=1</code> to enable <a href="https://github.com/Blockstream/electrs/#light-mode" target="_blank" rel="noopener">esplora-electrs's light mode</a>.</p> <p>Set <code>-e ONION_URL=http://xyz.onion</code> to enable the <code>Onion-Location</code> header.</p> <h2>Pull tor directly from Docker Hub - <code>blockstream/tor:latest</code></h2> <p>Run: <code>docker -d --name hidden_service blockstream/tor:latest tor -f /home/tor/torrc</code> (could add a <code>-v /extra/torrc:/home/tor/torrc</code>, if you have a custom torrc)</p> <p>Example torrc:</p> <pre><code class="plaintext">DataDirectory /home/tor/tor PidFile /var/run/tor/tor.pid <p>ControlSocket /var/run/tor/control GroupWritable RelaxDirModeCheck ControlSocketsGroupWritable 1 SocksPort unix:/var/run/tor/socks WorldWritable SocksPort 9050</p> <p>CookieAuthentication 1 CookieAuthFileGroupReadable 1 CookieAuthFile /var/run/tor/control.authcookie</p> <p>Log [handshake]debug [*]notice stderr</p> <p>HiddenServiceDir /home/tor/tor/hidden_service_v3/ HiddenServiceVersion 3 HiddenServicePort 80 127.0.0.1:80</code></pre></p> <h2>License</h2> <p>MIT</p> </article> <!-- Article Completion Section --> <div class="reader-footer mt-5 pt-5"> <div class="reader-completion-box rounded-3 p-4 text-center"> <div class="reader-completion-icon mb-3"> <i class="fas fa-check-circle"></i> </div> <p class="reader-completion-text fw-semibold mb-1" style="font-size: 1rem;">You finished the article!</p> <p class="reader-completion-subtext small mb-4">Want to go deeper or explore more stories?</p> <div class="d-flex justify-content-center gap-3 flex-wrap"> <a href="https://github.com/Blockstream/esplora" target="_blank" rel="noopener noreferrer" class="btn btn-primary-custom btn-sm rounded-pill px-4"> <i class="fas fa-external-link-alt me-2"></i>Full Article on GitHub Trending </a> <a href="https://neshdevtech.com/news" class="reader-back-btn btn btn-sm rounded-pill px-4"> <i class="fas fa-newspaper me-2"></i>More Tech News </a> </div> </div> </div> <!-- Internal Linking CTA --> </div> </div> </div> <!-- Sidebar (desktop only) --> <div class="col-lg-4 col-xl-3 d-none d-lg-block"> <div class="sticky-top" style="top: 80px;"> <!-- Mini Controller Widget --> <div class="card border-0 shadow-sm mb-4 bg-surface"> <div class="card-body p-4"> <h6 class="fw-bold mb-3"><i class="fas fa-glasses me-2 text-primary-custom"></i>Reading Mode</h6> <p class="text-secondary-custom small mb-4">Adjust typography and theme to your preference.</p> <div class="mb-3"> <label class="text-muted-custom small d-block mb-2">Typography</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-serif">Serif</button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-font-sans">Sans</button> </div> </div> <div class="mb-4"> <label class="text-muted-custom small d-block mb-2">Text Scale</label> <div class="d-flex gap-2"> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-dec"><i class="fas fa-minus small"></i></button> <button class="btn btn-light btn-sm flex-grow-1 border-custom" id="sidebar-size-inc"><i class="fas fa-plus small"></i></button> </div> </div> <div class="border-top border-light-custom pt-3"> <a href="https://github.com/Blockstream/esplora" target="_blank" rel="noopener noreferrer" class="btn btn-outline-primary btn-sm w-100 rounded-pill"> View Original <i class="fas fa-external-link-alt ms-1"></i> </a> </div> </div> </div> <!-- Sidebar Related News --> <div class="card border-0 shadow-sm bg-surface"> <div class="card-header bg-transparent border-0 pt-4 pb-0 px-4"> <h5 class="card-title fw-bold mb-0" style="font-size: 1.05rem;"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h5> </div> <div class="card-body p-0"> <div class="list-group list-group-flush rounded-0 bg-transparent"> <a href="https://neshdevtech.com/news/the-symmetry-of-state-why-flutter-deserves-contextvalue-and-contextstate-CaVh5" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> The Symmetry of State: Why Flutter Deserves context.value and context.state </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>14 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/beyond-llms-how-world-models-are-changing-generative-media-6xNPu" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Beyond LLMs: How World Models Are Changing Generative Media </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>18 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/casablanca-how-an-unproduced-play-marched-into-movie-history-hO0SF" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Casablanca: How An Unproduced Play Marched into Movie History </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Hacker News</span> <span>21 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/serverless-dtls-CkrNs" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> Serverless DTLS </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Hacker News</span> <span>21 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/the-contract-discovery-bottleneck-I7acq" class="list-group-item list-group-item-action border-0 px-4 py-3 bg-transparent border-bottom border-light-custom hover-bg-light"> <h6 class="mb-1 fw-semibold text-truncate-2" style="font-size: 0.875rem; line-height: 1.4;"> The Contract Discovery Bottleneck </h6> <div class="d-flex align-items-center justify-content-between text-muted-custom mt-1" style="font-size: 0.72rem;"> <span>Dev.to</span> <span>23 hours ago</span> </div> </a> </div> </div> </div> </div> </div> </div> <!-- Mobile: Related News horizontal scroll strip --> <div class="d-block d-lg-none mt-4"> <h6 class="fw-bold mb-3 px-1"> <i class="fas fa-newspaper me-2 text-primary-custom"></i>Related Stories </h6> <div class="related-scroll-strip"> <a href="https://neshdevtech.com/news/the-symmetry-of-state-why-flutter-deserves-contextvalue-and-contextstate-CaVh5" class="related-scroll-card"> <p class="related-scroll-title">The Symmetry of State: Why Flutter Deserves context.value and context....</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>14 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/beyond-llms-how-world-models-are-changing-generative-media-6xNPu" class="related-scroll-card"> <p class="related-scroll-title">Beyond LLMs: How World Models Are Changing Generative Media</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>18 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/casablanca-how-an-unproduced-play-marched-into-movie-history-hO0SF" class="related-scroll-card"> <p class="related-scroll-title">Casablanca: How An Unproduced Play Marched into Movie History</p> <div class="related-scroll-meta"> <span>Hacker News</span> <span>21 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/serverless-dtls-CkrNs" class="related-scroll-card"> <p class="related-scroll-title">Serverless DTLS</p> <div class="related-scroll-meta"> <span>Hacker News</span> <span>21 hours ago</span> </div> </a> <a href="https://neshdevtech.com/news/the-contract-discovery-bottleneck-I7acq" class="related-scroll-card"> <p class="related-scroll-title">The Contract Discovery Bottleneck</p> <div class="related-scroll-meta"> <span>Dev.to</span> <span>23 hours ago</span> </div> </a> </div> </div> </div> </section> </main> <!-- Footer --> <!-- Footer --> <footer class="py-5 mt-5 position-relative overflow-hidden" style="background: var(--surface); border-top: 1px solid var(--border); box-shadow: var(--shadow-lg);"> <!-- Decorative subtle background gradients --> <div class="position-absolute" style="top: -200px; right: -200px; width: 400px; height: 400px; background: rgba(var(--primary-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="position-absolute" style="bottom: -200px; left: -200px; width: 400px; height: 400px; background: rgba(var(--accent-rgb), 0.03); filter: blur(100px); border-radius: 50%; pointer-events: none;"></div> <div class="container position-relative z-3"> <div class="row g-4"> <!-- Column 1: Brand & About --> <div class="col-lg-4 col-md-6"> <div class="d-flex align-items-center mb-3"> <span class="brand-mark me-2" style="width: 42px; height: 42px;" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg> </span> <span class="fs-4 fw-extrabold text-primary-custom" style="letter-spacing: -0.05em; font-family: 'JetBrains Mono', monospace;">NeshDevTech</span> </div> <p class="text-secondary-custom mb-4 pe-lg-4" style="font-size: 0.95rem; line-height: 1.6;">NeshDevTech is a professional technology firm passionate about creating innovative, high-performance web and mobile solutions.</p> <div class="d-flex gap-2"> </div> </div> <!-- Column 2: Navigation Links --> <div class="col-lg-2 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Quick Navigation</h6> <ul class="list-unstyled mb-0"> <li class="mb-2"><a href="https://neshdevtech.com" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Home</a></li> <li class="mb-2"><a href="https://neshdevtech.com/projects" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Projects</a></li> <li class="mb-2"><a href="https://neshdevtech.com/blog" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Tech Blog</a></li> <li class="mb-2"><a href="https://neshdevtech.com/pricing" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Get Quote</a></li> <li class="mb-2"><a href="https://neshdevtech.com/contact" class="footer-link text-secondary-custom text-decoration-none d-inline-block">Contact Me</a></li> </ul> </div> <!-- Column 3: Tech Stack & Services --> <div class="col-lg-3 col-md-6 col-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Core Services</h6> <ul class="list-unstyled mb-0"> <li class="text-secondary-custom mb-2 small"><i class="bi bi-rocket-takeoff text-primary-custom me-2"></i>Custom Laravel Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-laptop text-primary-custom me-2"></i>SPA (React & Vue.js)</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-phone-vibrate text-primary-custom me-2"></i>Flutter Mobile Apps</li> <li class="text-secondary-custom mb-2 small"><i class="bi bi-search text-primary-custom me-2"></i>SEO & Performance Opt</li> </ul> </div> <!-- Column 4: Contact & Availability --> <div class="col-lg-3 col-md-6"> <h6 class="text-primary-custom fw-bold text-uppercase mb-3" style="font-size: 0.85rem; letter-spacing: 0.05em;">Get in Touch</h6> <ul class="list-unstyled mb-3"> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-geo-alt text-primary-custom me-2 fs-6"></i>Nairobi, Kenya </li> <li class="text-secondary-custom mb-2 small d-flex align-items-center"> <i class="bi bi-envelope-at text-primary-custom me-2 fs-6"></i> <a href="mailto:info@neshdevtech.com" class="text-secondary-custom text-decoration-none footer-link">info@neshdevtech.com</a> </li> </ul> <div class="bg-primary-custom-light border border-primary-custom border-opacity-10 p-3 rounded-3" style="border-radius: 12px !important;"> <small class="d-block fw-semibold text-primary-custom mb-1"><i class="bi bi-patch-check-fill me-1"></i>Available for Projects</small> <p class="mb-0 text-secondary-custom small" style="font-size: 0.8rem; line-height: 1.4;">Need a high-performing system? Let's discuss your project.</p> </div> </div> </div> <hr style="border-color: var(--border); opacity: 0.8; margin: 2.5rem 0 2rem;"> <div class="row align-items-center"> <div class="col-md-6 text-center text-md-start mb-2 mb-md-0"> <p class="mb-0 text-secondary-custom small">© 2026 NeshDevTech. All rights reserved.</p> </div> <div class="col-md-6 text-center text-md-end"> <p class="mb-0 text-secondary-custom small">Built with <i class="bi bi-heart-fill text-danger"></i></p> </div> </div> </div> </footer> <!-- Bootstrap JS --> <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> <!-- Custom JavaScript --> <script> // Theme Management System class ThemeManager { constructor() { this.themeToggle = document.getElementById('themeToggle'); this.themeIcon = document.getElementById('theme-icon'); this.mobileThemeToggle = document.getElementById('mobileThemeToggle'); this.mobileThemeIcon = document.getElementById('mobile-theme-icon'); this.init(); } init() { // Get current theme (already applied in head) const currentTheme = document.documentElement.getAttribute('data-theme') || 'light'; // Update icons to match current theme this.updateIcon(currentTheme); // Listen for desktop theme toggle clicks if (this.themeToggle) { this.themeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for mobile theme toggle clicks if (this.mobileThemeToggle) { this.mobileThemeToggle.addEventListener('click', (e) => { e.preventDefault(); this.toggleTheme(); }); } // Listen for system theme changes window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => { if (!localStorage.getItem('theme')) { this.setTheme(e.matches ? 'dark' : 'light'); } }); } setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-bs-theme', theme); if (theme === 'dark') { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } localStorage.setItem('theme', theme); this.updateThemeColor(theme); this.updateIcon(theme); } updateThemeColor(theme) { document.querySelectorAll('meta[name="theme-color"]').forEach((meta) => { meta.setAttribute('content', theme === 'dark' ? '#1c2028' : '#e4e8f0'); }); } toggleTheme() { const currentTheme = document.documentElement.getAttribute('data-theme'); const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; this.setTheme(newTheme); // Close mobile menu if open const navbarCollapse = document.querySelector('.navbar-collapse'); if (navbarCollapse && navbarCollapse.classList.contains('show') && window.innerWidth < 992) { setTimeout(() => { const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); }, 150); } } updateIcon(theme) { const iconClass = theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-fill'; // Update desktop icon if (this.themeIcon) { this.themeIcon.className = iconClass; } // Update mobile icon if (this.mobileThemeIcon) { this.mobileThemeIcon.className = iconClass; } } } // Initialize theme manager when DOM is loaded document.addEventListener('DOMContentLoaded', () => { new ThemeManager(); // Initialize navbar live clock (function initNavClock(){ const clockEls = Array.from(document.querySelectorAll('[data-clock]')); if (!clockEls.length) return; const locale = navigator.language || 'en-US'; // Detect 12/24h by locale: use resolvedOptions hourCycle when available const dtf = new Intl.DateTimeFormat(locale, { hour: 'numeric' }); const uses12h = (dtf.resolvedOptions().hour12 === true); const fmt = new Intl.DateTimeFormat(locale, { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: uses12h }); const update = () => { const now = new Date(); const text = fmt.format(now); clockEls.forEach(el => { el.textContent = text; }); }; update(); setInterval(update, 1000); })(); }); // Smooth scrolling for anchor links document.querySelectorAll('a[href^="#"]').forEach(anchor => { anchor.addEventListener('click', function (e) { e.preventDefault(); const target = document.querySelector(this.getAttribute('href')); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } }); }); // Navbar background on scroll window.addEventListener('scroll', () => { const navbar = document.querySelector('.navbar'); if (window.scrollY > 50) { navbar.style.backdropFilter = 'blur(20px)'; navbar.style.backgroundColor = 'rgba(var(--background-rgb), 0.8)'; } else { navbar.style.backdropFilter = 'blur(10px)'; navbar.style.backgroundColor = 'var(--background)'; } }); // Add fade-in animation to elements when they come into view const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -50px 0px' }; const observer = new IntersectionObserver((entries) => { entries.forEach(entry => { if (entry.isIntersecting) { entry.target.classList.add('animate-fade-in-up'); } }); }, observerOptions); // Observe elements with animation class document.addEventListener('DOMContentLoaded', () => { document.querySelectorAll('.animate-on-scroll').forEach(el => { observer.observe(el); }); }); // Enhanced mobile menu functionality document.addEventListener('DOMContentLoaded', function() { const navbarToggler = document.querySelector('.navbar-toggler'); const navbarCollapse = document.querySelector('.navbar-collapse'); const navLinks = document.querySelectorAll('.navbar-nav .nav-link'); const socialLinks = document.querySelectorAll('.navbar-nav .d-flex a'); let isToggling = false; // Prevent rapid clicking // Enhanced menu close function function closeMenu() { if (navbarCollapse && navbarCollapse.classList.contains('show')) { // Use Bootstrap's collapse API for smooth transition const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.hide(); } // Reset hamburger animation and aria-expanded if (navbarToggler) { navbarToggler.classList.remove('active'); navbarToggler.setAttribute('aria-expanded', 'false'); } } // Enhanced hamburger toggle functionality if (navbarToggler) { navbarToggler.addEventListener('click', function(e) { e.preventDefault(); // Prevent rapid clicking if (isToggling) return; isToggling = true; const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen) { closeMenu(); } else { // Open menu const bsCollapse = new bootstrap.Collapse(navbarCollapse, { toggle: false }); bsCollapse.show(); navbarToggler.setAttribute('aria-expanded', 'true'); } // Reset toggle flag after animation setTimeout(() => { isToggling = false; }, 350); }); } // Close menu when clicking outside document.addEventListener('click', function(event) { const isClickInsideNav = navbarCollapse.contains(event.target); const isToggler = navbarToggler.contains(event.target); const isMenuOpen = navbarCollapse.classList.contains('show'); if (!isClickInsideNav && !isToggler && isMenuOpen && window.innerWidth < 992) { closeMenu(); } }); // Close menu when clicking on navigation links (mobile) navLinks.forEach(function(link) { link.addEventListener('click', function(e) { const isMenuOpen = navbarCollapse.classList.contains('show'); if (isMenuOpen && window.innerWidth < 992 && !this.classList.contains('dropdown-toggle')) { // Add a small delay to let the link navigation start setTimeout(() => { closeMenu(); }, 100); } }); }); // Close menu when clicking on social links (mobile) socialLinks.forEach(function(link) { link.addEventListener('click', function() { if (window.innerWidth < 992) { setTimeout(() => { closeMenu(); }, 100); } }); }); // Escape key closes menu document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && window.innerWidth < 992) { closeMenu(); } }); // Handle window resize to close menu when switching to desktop window.addEventListener('resize', function() { if (window.innerWidth >= 992) { closeMenu(); } }); // Theme toggle functionality is handled by ThemeManager class // No separate event listener needed here // Prevent menu items from being too small on touch devices if ('ontouchstart' in window) { navLinks.forEach(function(link) { link.style.minHeight = '44px'; link.style.display = 'flex'; link.style.alignItems = 'center'; }); } // Add smooth scrolling for anchor links within pages navLinks.forEach(function(link) { const href = link.getAttribute('href'); if (href && href.startsWith('#')) { link.addEventListener('click', function(e) { e.preventDefault(); const target = document.querySelector(href); if (target) { target.scrollIntoView({ behavior: 'smooth', block: 'start' }); } closeMenu(); }); } }); }); </script> <script> // Sticky navbar scroll enhancement (desktop only) (function () { const navbar = document.querySelector('.navbar'); if (!navbar) return; let ticking = false; const SCROLL_THRESHOLD = 20; // px function updateNavbar() { if (window.innerWidth >= 992) { if (window.scrollY > SCROLL_THRESHOLD) { navbar.classList.add('scrolled'); } else { navbar.classList.remove('scrolled'); } } else { // Always remove on mobile so it doesn't affect mobile look navbar.classList.remove('scrolled'); } ticking = false; } window.addEventListener('scroll', function () { if (!ticking) { requestAnimationFrame(updateNavbar); ticking = true; } }, { passive: true }); window.addEventListener('resize', updateNavbar, { passive: true }); // Run once on page load in case page is already scrolled (e.g. back-navigation) updateNavbar(); })(); </script> <script> // Lightweight toast badge notifications (function(){ function ensureContainer(){ let c = document.getElementById('toast-badge-container'); if(!c){ c = document.createElement('div'); c.id = 'toast-badge-container'; c.setAttribute('aria-live','polite'); c.setAttribute('aria-atomic','true'); document.body.appendChild(c); } return c; } function iconFor(type){ switch(type){ case 'success': return '<i class="bi bi-check-circle"></i>'; case 'warning': return '<i class="bi bi-exclamation-triangle"></i>'; case 'error': return '<i class="bi bi-x-circle"></i>'; default: return '<i class="bi bi-info-circle"></i>'; } } window.notifyBadge = function(message, opts={}){ const { type = 'success', timeout = 2500 } = opts; const container = ensureContainer(); const el = document.createElement('div'); el.className = 'toast-badge ' + type; el.setAttribute('role','status'); el.innerHTML = '<span class="icon">'+iconFor(type)+'</span><span>'+message+'</span>'; container.appendChild(el); requestAnimationFrame(()=>{ el.classList.add('show'); }); const remove = () => { el.classList.remove('show'); setTimeout(()=> el.remove(), 200); }; setTimeout(remove, timeout); return { remove }; }; // Global copy helper used by inline buttons window.copyToClipboard = function(url){ try { navigator.clipboard.writeText(url).then(()=>{ // Update the clicked button, if available let btn = document.activeElement && document.activeElement.tagName === 'BUTTON' ? document.activeElement : (window.event && window.event.target && window.event.target.closest ? window.event.target.closest('button') : null); if (btn){ const original = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check me-1"></i> Copied!'; btn.classList.remove('btn-outline-secondary'); btn.classList.add('btn-success'); setTimeout(()=>{ btn.innerHTML = original; btn.classList.remove('btn-success'); btn.classList.add('btn-outline-secondary'); }, 2000); } notifyBadge('Link copied to clipboard', { type: 'success' }); }).catch((err)=>{ console.error('Clipboard error:', err); notifyBadge('Could not copy link', { type: 'error' }); }); } catch (e) { console.error(e); notifyBadge('Copy not supported', { type: 'error' }); } }; })(); </script> <a href="https://wa.me/254768033943?text=Hi%2C+I+visited+your+website+and+would+like+to+inquire+about+your+services." class="whatsapp-btn-float" target="_blank" rel="noopener noreferrer" aria-label="Chat on WhatsApp"> <i class="fab fa-whatsapp"></i> <span class="whatsapp-tooltip">Chat with me</span> </a> <style> .whatsapp-btn-float { position: fixed; bottom: 24px; right: 24px; z-index: 1050; display: flex; align-items: center; justify-content: center; background-color: #25d366; color: #fff !important; width: 60px; height: 60px; border-radius: 50%; box-shadow: 0 4px 16px rgba(37, 211, 102, 0.45); text-decoration: none; transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275); } .whatsapp-btn-float i { font-size: 32px; transition: transform 0.3s ease; } .whatsapp-btn-float:hover { background-color: #128c7e; transform: scale(1.1); box-shadow: 0 6px 20px rgba(18, 140, 126, 0.6); } .whatsapp-btn-float:hover i { transform: rotate(10deg); } /* Pulsing ring animation */ .whatsapp-btn-float::before { content: ''; position: absolute; width: 100%; height: 100%; border-radius: 50%; background-color: #25d366; opacity: 0.7; z-index: -1; animation: whatsapp-pulse 2s infinite; } @keyframes whatsapp-pulse { 0% { transform: scale(1); opacity: 0.7; } 100% { transform: scale(1.6); opacity: 0; } } /* Tooltip style utilizing site-wide theme colors */ .whatsapp-btn-float .whatsapp-tooltip { position: absolute; right: 76px; background-color: var(--surface); color: var(--text-primary); border: 1px solid var(--border); padding: 8px 14px; border-radius: 20px; font-size: 14px; font-weight: 600; white-space: nowrap; opacity: 0; visibility: hidden; transform: translateX(10px); transition: all 0.3s ease; box-shadow: var(--shadow); pointer-events: none; } .whatsapp-btn-float:hover .whatsapp-tooltip { opacity: 1; visibility: visible; transform: translateX(0); } /* Responsive Design */ @media (max-width: 768px) { .whatsapp-btn-float { bottom: 16px; right: 16px; width: 50px; height: 50px; } .whatsapp-btn-float i { font-size: 26px; } .whatsapp-btn-float .whatsapp-tooltip { display: none; /* Hide tooltip on small screens to prevent overlap issues */ } } </style> <!-- Mobile More Menu (Offcanvas Bottom Sheet) - Auth actions only --> <style> /* ══════════════════════════════════════════════════════════════ NEUMORPHISM REVAMP (front-end only) Soft-extruded UI: same-tone surfaces with dual light/dark shadows. This block sits at the end of the body so it layers over every page's own styles without touching page markup. ══════════════════════════════════════════════════════════════ */ /* ── Buttons: extruded, press-in on click ─────────────────────── */ .btn { box-shadow: var(--shadow) !important; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1) !important; } .btn:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .btn:active:not(.btn-link), .btn.show { transform: translateY(1px) scale(0.99) !important; box-shadow: var(--shadow-inset) !important; } .btn-primary-custom, .btn-primary, .btn-success { background: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow) !important; } .btn-primary-custom:hover, .btn-primary:hover, .btn-success:hover { background: var(--primary-dark) !important; border-color: var(--primary-dark) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-primary-custom, .btn-outline-primary, .btn-outline-success, .btn-outline-secondary, .btn-outline-danger, .btn-outline-dark, .btn-light { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow) !important; } .btn-outline-primary-custom:hover, .btn-outline-primary:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-success:hover { background-color: var(--primary-color) !important; border-color: var(--primary-color) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-secondary:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-danger:hover { background-color: #ef4444 !important; border-color: #ef4444 !important; color: #fff !important; box-shadow: var(--shadow-md) !important; } .btn-outline-dark:hover, .btn-light:hover { background-color: var(--text-secondary) !important; border-color: var(--text-secondary) !important; color: var(--background) !important; box-shadow: var(--shadow-md) !important; } .btn-dark { background-color: var(--text-primary) !important; border-color: var(--text-primary) !important; color: var(--background) !important; box-shadow: var(--shadow) !important; } /* ── Forms: pressed-in surfaces ────────────────────────────────── */ .form-control, .form-select { background-color: var(--surface) !important; border: 1px solid transparent !important; color: var(--text-primary) !important; border-radius: 14px !important; box-shadow: var(--shadow-inset) !important; transition: box-shadow 0.3s ease, background-color 0.25s ease !important; } .form-control:focus, .form-select:focus { background-color: var(--surface) !important; border-color: transparent !important; color: var(--text-primary) !important; box-shadow: var(--shadow-inset), 0 0 0 4px rgba(var(--primary-rgb), 0.15) !important; } .form-control::placeholder { color: var(--text-muted) !important; } .input-group-text { background-color: var(--surface-alt) !important; border: 1px solid transparent !important; color: var(--text-secondary) !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Navbar: floating neumorphic pill (tablet & desktop) ──────── */ .navbar { background-color: rgba(var(--background-rgb), 0.9) !important; border-bottom: 1px solid transparent !important; backdrop-filter: blur(16px) !important; -webkit-backdrop-filter: blur(16px) !important; box-shadow: var(--shadow-md) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-md) !important; } @media (min-width: 768px) { .navbar { top: 14px !important; left: 18px !important; right: 18px !important; width: auto !important; border-radius: 20px !important; padding: 0.6rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar { box-shadow: var(--shadow-lg) !important; } .navbar.scrolled { background-color: rgba(var(--background-rgb), 0.95) !important; border-bottom-color: transparent !important; padding: 0.5rem 0 !important; box-shadow: var(--shadow-lg) !important; } [data-theme="dark"] .navbar.scrolled { background-color: rgba(28, 32, 40, 0.95) !important; box-shadow: var(--shadow-lg) !important; } body { padding-top: 96px !important; } .reading-progress-container { top: 88px !important; } } /* ── Tablet refinements (768px - 991.98px) ────────────────────── */ @media (min-width: 768px) and (max-width: 991.98px) { /* Hamburger dropdown panel blends with the floating pill */ .navbar-collapse { border-radius: 20px !important; border-color: transparent !important; } body { padding-bottom: 0 !important; } .whatsapp-btn-float { bottom: 24px !important; } /* Hero: tighten the inline 80px top padding that assumed a full-height bar, so tablets don't get excessive empty space */ .hero-section-bg { padding-top: 40px !important; } } /* ── Neumorphic brand icon mark ────────────────────────────────── */ .brand-mark { width: 40px; height: 40px; flex-shrink: 0; border-radius: 13px; background-color: var(--surface) !important; color: var(--primary-color) !important; display: inline-flex; align-items: center; justify-content: center; box-shadow: var(--shadow) !important; transition: box-shadow 0.3s cubic-bezier(0.16, 1, 0.3, 1), transform 0.3s cubic-bezier(0.16, 1, 0.3, 1); } .brand-mark svg { width: 21px; height: 21px; } a:hover .brand-mark { box-shadow: var(--shadow-inset-sm) !important; transform: translateY(1px); } .mobile-logo .brand-mark { width: 34px; height: 34px; border-radius: 11px; } .mobile-logo .brand-mark svg { width: 17px; height: 17px; } /* ── Uploaded brand logo image ─────────────────────────────────── */ .brand-img { height: 38px; width: auto; max-width: 160px; object-fit: contain; flex-shrink: 0; } .brand-img-sm { height: 28px; max-width: 120px; } .brand-img-footer { height: 44px; max-width: 180px; } /* ── Mobile bars: floating neumorphic pills (phones) ──────────── */ @media (max-width: 767.98px) { .mobile-top-bar { top: 8px !important; left: 12px !important; right: 12px !important; height: 52px !important; border-radius: 16px !important; border-bottom: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } .mobile-bottom-bar { left: 12px !important; right: 12px !important; bottom: 12px !important; height: 58px !important; border-radius: 18px !important; border-top: none !important; background-color: rgba(var(--surface-rgb), 0.92) !important; box-shadow: var(--shadow-md) !important; } body { padding-top: 74px !important; padding-bottom: 86px !important; } .whatsapp-btn-float { bottom: 88px !important; } .reading-progress-container { top: 70px !important; } } /* ── Nav pill widgets: pressed-in ──────────────────────────────── */ .theme-toggle, .mobile-theme-toggle, .nav-clock { background-color: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow-inset-sm) !important; } /* ── Icon chips, markers & circles: extruded ───────────────────── */ .icon-wrap, .icon-wrapper, .expertise-card-icon, .timeline-marker, .mobile-more-icon-wrapper, .social-circle, .value-icon, .skill-icon-box { box-shadow: var(--shadow) !important; border-color: transparent !important; } /* ── Panels: extruded ──────────────────────────────────────────── */ .accordion-item { border-color: transparent !important; box-shadow: var(--shadow) !important; } .timeline-content { background: var(--surface) !important; border-radius: 12px !important; padding: 1rem 1.25rem !important; box-shadow: var(--shadow) !important; } .feature-item { background: var(--surface) !important; border-color: transparent !important; box-shadow: var(--shadow) !important; } .feature-item:hover { transform: translateY(-2px) !important; box-shadow: var(--shadow-md) !important; } .progress { background-color: var(--surface-alt) !important; box-shadow: var(--shadow-inset-sm) !important; } /* Card hover: consistent soft neu shadow */ .card-hover:hover, .project-card:hover, .blog-card:hover, .post-card:hover, .news-card:hover, .testimonial-card:hover, .hover-lift:hover, .stats-item:hover { box-shadow: var(--shadow-lg) !important; } </style> <script> document.addEventListener('DOMContentLoaded', function() { const readerCard = document.getElementById('reader-card'); if (!readerCard) return; // Settings Key constants const THEME_KEY = 'reader_mode_theme'; const FONT_KEY = 'reader_mode_font'; const SIZE_KEY = 'reader_mode_size'; // Available Options const themes = ['light', 'sepia', 'dark', 'oled']; const fonts = ['serif', 'sans']; const sizes = ['xs', 'sm', 'md', 'lg', 'xl']; // Default Configs const siteTheme = document.documentElement.getAttribute('data-theme') || 'light'; const defaultTheme = siteTheme === 'dark' ? 'dark' : 'light'; let currentTheme = localStorage.getItem(THEME_KEY) || defaultTheme; let currentFont = localStorage.getItem(FONT_KEY) || 'serif'; let currentSize = localStorage.getItem(SIZE_KEY) || 'md'; // Apply initial config applySettings(); // Register Action Listeners (Toolbar) document.getElementById('btn-font-serif').addEventListener('click', () => setFont('serif')); document.getElementById('btn-font-sans').addEventListener('click', () => setFont('sans')); document.getElementById('btn-size-decrease').addEventListener('click', () => adjustSize(-1)); document.getElementById('btn-size-increase').addEventListener('click', () => adjustSize(1)); document.getElementById('theme-light').addEventListener('click', () => setTheme('light')); document.getElementById('theme-sepia').addEventListener('click', () => setTheme('sepia')); document.getElementById('theme-dark').addEventListener('click', () => setTheme('dark')); document.getElementById('theme-oled').addEventListener('click', () => setTheme('oled')); // Register Action Listeners (Sidebar widgets, if present) const sidebarSerif = document.getElementById('sidebar-font-serif'); const sidebarSans = document.getElementById('sidebar-font-sans'); const sidebarSizeDec = document.getElementById('sidebar-size-dec'); const sidebarSizeInc = document.getElementById('sidebar-size-inc'); if (sidebarSerif) sidebarSerif.addEventListener('click', () => setFont('serif')); if (sidebarSans) sidebarSans.addEventListener('click', () => setFont('sans')); if (sidebarSizeDec) sidebarSizeDec.addEventListener('click', () => adjustSize(-1)); if (sidebarSizeInc) sidebarSizeInc.addEventListener('click', () => adjustSize(1)); // Action Core Functions function setTheme(theme) { currentTheme = theme; localStorage.setItem(THEME_KEY, theme); applySettings(); } function setFont(font) { currentFont = font; localStorage.setItem(FONT_KEY, font); applySettings(); } function adjustSize(delta) { let index = sizes.indexOf(currentSize); index = Math.max(0, Math.min(sizes.length - 1, index + delta)); currentSize = sizes[index]; localStorage.setItem(SIZE_KEY, currentSize); applySettings(); } function applySettings() { // 1. Reset theme classes and apply selected themes.forEach(t => readerCard.classList.remove('reader-theme-' + t)); readerCard.classList.add('reader-theme-' + currentTheme); // Update Theme circles highlight themes.forEach(t => { const circle = document.getElementById('theme-' + t); if (circle) { if (t === currentTheme) { circle.classList.add('active'); } else { circle.classList.remove('active'); } } }); // 2. Reset Font classes and apply selected fonts.forEach(f => readerCard.classList.remove('reader-font-' + f)); readerCard.classList.add('reader-font-' + currentFont); // Highlight Font buttons const serifBtns = [document.getElementById('btn-font-serif'), document.getElementById('sidebar-font-serif')]; const sansBtns = [document.getElementById('btn-font-sans'), document.getElementById('sidebar-font-sans')]; serifBtns.forEach(btn => { if (btn) { if (currentFont === 'serif') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); sansBtns.forEach(btn => { if (btn) { if (currentFont === 'sans') { btn.classList.add('btn-primary-custom'); btn.classList.remove('btn-light'); } else { btn.classList.add('btn-light'); btn.classList.remove('btn-primary-custom'); } } }); // 3. Reset Sizing classes and apply selected sizes.forEach(s => readerCard.classList.remove('reader-size-' + s)); readerCard.classList.add('reader-size-' + currentSize); } // Reading Scroll progress calculation const progressBar = document.getElementById('reading-progress-bar'); window.addEventListener('scroll', () => { const documentHeight = document.documentElement.scrollHeight - window.innerHeight; if (documentHeight > 0) { const scrollProgress = (window.pageYOffset / documentHeight) * 100; progressBar.style.width = scrollProgress + '%'; } }); }); </script> <script> // ══════════════════════════════════════════════════════════════ // PWA: Service Worker registration + install prompt // ══════════════════════════════════════════════════════════════ (function () { const PWA_VERSION = '20260805-1'; // ---- Service worker ------------------------------------ if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js?v=' + PWA_VERSION).then((reg) => { // Apply updates (new deploy) on the next load without disruption reg.addEventListener('updatefound', () => { const newWorker = reg.installing; if (!newWorker) return; newWorker.addEventListener('statechange', () => { if (newWorker.state === 'installed' && navigator.serviceWorker.controller) { newWorker.postMessage({ type: 'SKIP_WAITING' }); } }); }); }).catch(() => {}); }); } // ---- Install prompt -------------------------------------- let deferredPrompt = null; const bannerId = 'pwa-install-banner'; const DISMISS_KEY = 'nesh-install-dismissed'; function showInstallBanner() { if (localStorage.getItem(DISMISS_KEY) || document.getElementById(bannerId)) return; const banner = document.createElement('div'); banner.id = bannerId; banner.setAttribute('role', 'dialog'); banner.setAttribute('aria-label', 'Install app'); banner.innerHTML = '<div class="pwa-install-content">' + '<span class="pwa-install-icon">' + '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>' + '</span>' + '<div class="pwa-install-text">' + '<strong>Install NeshDevTech</strong>' + '<span>Get the app on your device</span>' + '</div>' + '<button type="button" class="pwa-install-btn" id="pwa-install-btn">Install</button>' + '<button type="button" class="pwa-install-close" id="pwa-install-close" aria-label="Dismiss">×</button>' + '</div>'; document.body.appendChild(banner); document.getElementById('pwa-install-btn').addEventListener('click', () => { if (!deferredPrompt) return; deferredPrompt.prompt(); deferredPrompt.userChoice.finally(() => { deferredPrompt = null; hideInstallBanner(); }); }); document.getElementById('pwa-install-close').addEventListener('click', () => { localStorage.setItem(DISMISS_KEY, '1'); hideInstallBanner(); }); requestAnimationFrame(() => banner.classList.add('show')); } function hideInstallBanner() { const banner = document.getElementById(bannerId); if (banner) { banner.classList.remove('show'); setTimeout(() => banner.remove(), 250); } } window.addEventListener('beforeinstallprompt', (e) => { e.preventDefault(); deferredPrompt = e; showInstallBanner(); }); window.addEventListener('appinstalled', () => { deferredPrompt = null; hideInstallBanner(); localStorage.setItem(DISMISS_KEY, '1'); }); })(); </script> <style> .pwa-install-content { display: flex; align-items: center; gap: 12px; padding: 14px 18px; } #pwa-install-banner { position: fixed; left: 50%; transform: translate(-50%, 20px); bottom: 84px; z-index: 1070; background: var(--surface); color: var(--text-primary); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow-lg); opacity: 0; transition: opacity .25s cubic-bezier(.16, 1, .3, 1), transform .25s cubic-bezier(.16, 1, .3, 1); width: min(420px, calc(100vw - 32px)); } #pwa-install-banner.show { opacity: 1; transform: translate(-50%, 0); } .pwa-install-icon { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; flex: 0 0 40px; border-radius: 12px; background: rgba(var(--primary-rgb), 0.1); border: 1px solid rgba(var(--primary-rgb), 0.2); color: var(--primary-color); } .pwa-install-icon svg { width: 20px; height: 20px; } .pwa-install-text { display: flex; flex-direction: column; flex: 1; min-width: 0; } .pwa-install-text strong { font-size: 0.92rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .pwa-install-text span { font-size: 0.78rem; color: var(--text-secondary); } .pwa-install-btn { background: var(--primary-color); border: none; color: #fff; font-weight: 600; font-size: 0.85rem; padding: 8px 18px; border-radius: 9999px; cursor: pointer; white-space: nowrap; box-shadow: 0 4px 12px rgba(var(--primary-rgb), 0.25); transition: all .2s ease; } .pwa-install-btn:hover { background: var(--primary-dark); transform: translateY(-1px); } .pwa-install-close { background: transparent; border: none; color: var(--text-muted); font-size: 1.4rem; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 50%; transition: background-color .2s ease, color .2s ease; } .pwa-install-close:hover { background: var(--surface-alt); color: var(--text-primary); } @media (min-width: 992px) { #pwa-install-banner { bottom: 24px; } } </style> </div><!-- /.page-wrapper --> </body> </html>