Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 7 additions & 16 deletions frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { categoryTheme, currentCategory, detectCategoryFromRoute } from './stores/category.js';
import { location } from 'svelte-spa-router';
import { resetPageMeta } from './lib/meta.js';
import { normalizeLocation } from './lib/normalizePath.js';

// Early OAuth result detection — runs before routes mount.
// Backend redirects here with ?oauth_platform=X&oauth_verified=true/false&oauth_error=...
Expand Down Expand Up @@ -48,22 +49,12 @@
}
}

// The portal uses hash routing. Normalize pasted/local links such as
// /claim/poap/:token so mint links work on localhost and 127.0.0.1.
{
const path = window.location.pathname;
const shouldNormalize =
!window.location.hash &&
(path.startsWith('/claim/poap/') || path.startsWith('/community/poaps/'));

if (shouldNormalize) {
window.history.replaceState(
{},
'',
`/#${path}${window.location.search || ''}`
);
}
}
// The portal uses hash routing. Direct/path-based links (sidebar hrefs opened
// in a new tab, refreshes of a path route, shared or indexed links such as
// /testnets and /metrics) arrive without a hash and would otherwise 404.
// Rewrite any such path into its hash equivalent so the router resolves it;
// unknown paths still fall through to the router's own NotFound view.
normalizeLocation(window);

// State for sidebar toggle on mobile and collapse on desktop
let sidebarOpen = $state(false);
Expand Down
56 changes: 28 additions & 28 deletions frontend/src/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@
{#if !collapsed && getActiveSection() === 'global'}
<div class="pl-5">
<a
href="/testnets"
href="#/testnets"
onclick={(e) => { e.preventDefault(); navigate('/testnets'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -149,7 +149,7 @@
Testnets
</a>
<a
href="/metrics"
href="#/metrics"
onclick={(e) => { e.preventDefault(); navigate('/metrics'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -193,7 +193,7 @@
{#if !collapsed && getActiveSection() === 'builder'}
<div class="pl-5">
<a
href="/builders/contributions"
href="#/builders/contributions"
onclick={(e) => { e.preventDefault(); navigate('/builders/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand All @@ -202,7 +202,7 @@
Contributions
</a>
<a
href="/builders/leaderboard"
href="#/builders/leaderboard"
onclick={(e) => { e.preventDefault(); navigate('/builders/leaderboard'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand All @@ -211,7 +211,7 @@
Leaderboard
</a>
<a
href="/builders/resources"
href="#/builders/resources"
onclick={(e) => { e.preventDefault(); navigate('/builders/resources'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -246,7 +246,7 @@
{#if !collapsed && getActiveSection() === 'validator'}
<div class="pl-5">
<a
href="/validators/contributions"
href="#/validators/contributions"
onclick={(e) => { e.preventDefault(); navigate('/validators/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -255,7 +255,7 @@
Contributions
</a>
<a
href="/validators/leaderboard"
href="#/validators/leaderboard"
onclick={(e) => { e.preventDefault(); navigate('/validators/leaderboard'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -264,7 +264,7 @@
Leaderboard
</a>
<a
href="/validators/participants"
href="#/validators/participants"
onclick={(e) => { e.preventDefault(); navigate('/validators/participants'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -273,7 +273,7 @@
Participants
</a>
<a
href="/validators/waitlist"
href="#/validators/waitlist"
onclick={(e) => { e.preventDefault(); navigate('/validators/waitlist'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -308,7 +308,7 @@
{#if !collapsed && getActiveSection() === 'community'}
<div class="pl-5">
<a
href="/community/contributions"
href="#/community/contributions"
onclick={(e) => { e.preventDefault(); navigate('/community/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -317,7 +317,7 @@
Contributions
</a>
<a
href="/community/referrals"
href="#/community/referrals"
onclick={(e) => { e.preventDefault(); navigate('/community/referrals'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -326,7 +326,7 @@
Referrals
</a>
<a
href="/community/poaps"
href="#/community/poaps"
onclick={(e) => { e.preventDefault(); navigate('/community/poaps'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -362,7 +362,7 @@
{#if !collapsed && getActiveSection() === 'steward' && $userStore.user?.steward}
<div class="pl-5">
<a
href="/stewards/submissions"
href="#/stewards/submissions"
onclick={(e) => { e.preventDefault(); navigate('/stewards/submissions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]'
Expand All @@ -371,7 +371,7 @@
Contribution Submissions
</a>
<a
href="/stewards/manage-users"
href="#/stewards/manage-users"
onclick={(e) => { e.preventDefault(); navigate('/stewards/manage-users'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -566,7 +566,7 @@
{#if getActiveSection() === 'global'}
<div class="pl-5">
<a
href="/testnets"
href="#/testnets"
onclick={(e) => { e.preventDefault(); navigate('/testnets'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/testnets') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -575,7 +575,7 @@
Testnets
</a>
<a
href="/metrics"
href="#/metrics"
onclick={(e) => { e.preventDefault(); navigate('/metrics'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/metrics') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -608,7 +608,7 @@
{#if getActiveSection() === 'builder'}
<div class="pl-5">
<a
href="/builders/contributions"
href="#/builders/contributions"
onclick={(e) => { e.preventDefault(); navigate('/builders/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/contributions') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand All @@ -617,7 +617,7 @@
Contributions
</a>
<a
href="/builders/leaderboard"
href="#/builders/leaderboard"
onclick={(e) => { e.preventDefault(); navigate('/builders/leaderboard'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/leaderboard') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand All @@ -626,7 +626,7 @@
Leaderboard
</a>
<a
href="/builders/resources"
href="#/builders/resources"
onclick={(e) => { e.preventDefault(); navigate('/builders/resources'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/builders/resources') ? 'border-[#EE8D24]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -654,7 +654,7 @@
{#if getActiveSection() === 'validator'}
<div class="pl-5">
<a
href="/validators/contributions"
href="#/validators/contributions"
onclick={(e) => { e.preventDefault(); navigate('/validators/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/contributions') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -663,7 +663,7 @@
Contributions
</a>
<a
href="/validators/leaderboard"
href="#/validators/leaderboard"
onclick={(e) => { e.preventDefault(); navigate('/validators/leaderboard'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/leaderboard') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -672,7 +672,7 @@
Leaderboard
</a>
<a
href="/validators/participants"
href="#/validators/participants"
onclick={(e) => { e.preventDefault(); navigate('/validators/participants'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/participants') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand All @@ -681,7 +681,7 @@
Participants
</a>
<a
href="/validators/waitlist"
href="#/validators/waitlist"
onclick={(e) => { e.preventDefault(); navigate('/validators/waitlist'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/validators/waitlist') ? 'border-[#387DE8]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -709,7 +709,7 @@
{#if getActiveSection() === 'community'}
<div class="pl-5">
<a
href="/community/contributions"
href="#/community/contributions"
onclick={(e) => { e.preventDefault(); navigate('/community/contributions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/contributions') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -718,7 +718,7 @@
Contributions
</a>
<a
href="/community/referrals"
href="#/community/referrals"
onclick={(e) => { e.preventDefault(); navigate('/community/referrals'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/referrals') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand All @@ -727,7 +727,7 @@
Referrals
</a>
<a
href="/community/poaps"
href="#/community/poaps"
onclick={(e) => { e.preventDefault(); navigate('/community/poaps'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/community/poaps') ? 'border-[#8D81E1]' : 'border-[#f5f5f5]'
Expand Down Expand Up @@ -755,7 +755,7 @@
{#if getActiveSection() === 'steward' && $userStore.user?.steward}
<div class="pl-5">
<a
href="/stewards/submissions"
href="#/stewards/submissions"
onclick={(e) => { e.preventDefault(); navigate('/stewards/submissions'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/stewards/submissions') ? 'border-[#19A663]' : 'border-[#f5f5f5]'
Expand All @@ -764,7 +764,7 @@
Contribution Submissions
</a>
<a
href="/stewards/manage-users"
href="#/stewards/manage-users"
onclick={(e) => { e.preventDefault(); navigate('/stewards/manage-users'); }}
class="flex items-center border-l-[1.5px] px-3 py-2 text-[14px] font-medium text-black tracking-[0.28px] {
isActive('/stewards/manage-users') ? 'border-[#19A663]' : 'border-[#f5f5f5]'
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/lib/normalizePath.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* Path-to-hash route normalization for the hash-based portal router.
*
* The portal uses `svelte-spa-router`, which resolves routes from the URL hash
* (e.g. `/#/testnets`). Several entry points still produce plain path URLs
* (e.g. `/testnets`, `/metrics`): sidebar `href` attributes, links opened in a
* new tab, refreshes of a path-based route, and shared/indexed links. When such
* a URL is opened directly, the hash is empty, the router has nothing to match,
* and the user lands on a 404 / NotFound view.
*
* This module decides whether a plain path URL should be rewritten into its
* hash equivalent so the router can resolve it. It is deliberately
* route-agnostic: any unknown path is forwarded to the router, which renders
* its own NotFound for genuinely missing routes. Static assets and known
* server-handled prefixes (OAuth, API) are left untouched.
*/

// Prefixes that are handled outside the SPA router and must never be
// rewritten into hash routes.
const RESERVED_PREFIXES = ['/api', '/oauth', '/static', '/assets', '/media'];

/**
* Returns true when `pathname` looks like a request for a static file, e.g.
* `/favicon.ico`, `/robots.txt`, `/assets/app.123.js`. Such requests must not
* be turned into hash routes. We treat a final path segment that contains a
* dot as a file request.
*
* @param {string} pathname
* @returns {boolean}
*/
function looksLikeStaticFile(pathname) {
const lastSegment = pathname.split('/').pop() || '';
return lastSegment.includes('.');
}

/**
* Given a `window.location`-like object, compute the normalized URL the app
* should switch to, or `null` when no normalization is needed.
*
* Normalization applies when ALL of the following hold:
* - there is no existing hash (a hash route is already resolvable), and
* - the path is not the root `/` (root maps to `#/` implicitly), and
* - the path is not a reserved/server-handled prefix, and
* - the path does not look like a static file request.
*
* The returned URL preserves the original query string and moves the path into
* the hash, e.g. `/testnets?foo=1` -> `/#/testnets?foo=1`.
*
* @param {{ pathname: string, hash?: string, search?: string }} location
* @returns {string | null} the URL to replace, or null if no change is needed
*/
export function computeNormalizedUrl(location) {
const pathname = location.pathname || '/';
const hash = location.hash || '';
const search = location.search || '';

if (hash) return null;
if (pathname === '/' || pathname === '') return null;
if (RESERVED_PREFIXES.some((prefix) => pathname.startsWith(prefix))) return null;
if (looksLikeStaticFile(pathname)) return null;

return `/#${pathname}${search}`;
}

/**
* Side-effecting helper for use at app startup. Rewrites the current URL via
* `history.replaceState` when normalization is needed, so the hash router can
* resolve direct/path-based links without a full navigation or 404.
*
* Safe to call when `window`/`history` are unavailable (e.g. SSR/tests): it
* simply does nothing and returns false.
*
* @param {Window} [win=window]
* @returns {boolean} true if the URL was rewritten
*/
export function normalizeLocation(win = typeof window !== 'undefined' ? window : undefined) {
if (!win || !win.location || !win.history) return false;

const target = computeNormalizedUrl(win.location);
if (!target) return false;

win.history.replaceState({}, '', target);
return true;
}
Loading