Add a slim, dismissible site-links strip to the map

The desktop footer is hidden on full-bleed map routes, leaving the
Newsletter/License/Privacy/Collaborators links (and the "not sponsored by
OpenSats" note) unreachable from the homepage. MapFooterStrip overlays them
inside the map container without stealing map height:

- Desktop: a slim full-width bottom bar.
- Mobile: a tiny collapsed "Links" chip that expands to a compact, scrollable
  row.
- Dismissible (useMapFooterHidden, device-local, same-tab synced) and
  restorable from Settings -> Appearance.
- The map's OSM attribution moved up (md:bottom-7) so the bar doesn't cover it.
This commit is contained in:
SkateSpots 2026-10-05 09:15:51 +07:00
parent 51ff603670
commit b37d627138
7 changed files with 151 additions and 1 deletions

View file

@ -1148,6 +1148,14 @@ session with **no other memory of this project**. So:
say this honestly (2026-09-29). **Still future:** real MLS
(`ts-mls`) instead of group-key crypto, plus Cordn RPC interop. **Lesson: grep
`src` for the feature before building it — this was nearly duplicated.**
- **Map site-links strip (2026-10-05):** the desktop footer is hidden on the map
(full-bleed), so `components/MapFooterStrip.tsx` surfaces the same links
(Newsletter/License/Privacy/Collaborators + "not sponsored by OpenSats") as an
overlay inside the map container: a **slim full-width bar on desktop** and a
**tiny collapsed "Links" chip on mobile** (expands to a compact scrollable row).
Dismissible via `useMapFooterHidden` (`skatespots:map-footer-hidden`, same-tab
synced), restorable in **Settings → Appearance**. The map's OpenStreetMap
attribution moved to `md:bottom-7` so the bar doesn't cover it.
- **Map layout — controls float (2026-09-29):** on mobile the `MapPage` toolbar
is **`absolute` over the map** (`pointer-events-none` wrapper + gradient +
`pointer-events-auto` inner) so the map fills the whole container; `md:static`

View file

@ -0,0 +1,86 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronUp, X } from 'lucide-react';
import { useTranslation } from '@/contexts/language';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { cn } from '@/lib/utils';
/**
* The site links that live in the desktop footer — surfaced on the map
* (a full-bleed route where the footer is hidden) without stealing map height.
*
* Desktop: a slim full-width bar. Mobile: a tiny collapsed "Links" chip that
* expands to a compact row. Dismissible, and restorable from Settings →
* Appearance.
*/
export function MapFooterStrip() {
const { t } = useTranslation();
const [hidden, setHidden] = useMapFooterHidden();
const [open, setOpen] = useState(false);
if (hidden) return null;
const links = [
{ to: '/newsletter', label: t('nav.newsletter') },
{ to: '/license', label: t('chromeUi.license') },
{ to: '/privacy', label: t('chromeUi.privacy') },
{ to: '/supporters', label: t('chromeUi.collaborators') },
];
return (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-[1000]">
{/* Desktop — slim full-width bar */}
<div className="pointer-events-auto hidden w-full items-center justify-between gap-4 border-t border-white/10 bg-black/85 px-4 py-1.5 text-[11px] text-gray-400 backdrop-blur md:flex">
<div className="flex items-center gap-4">
{links.map((l) => (
<Link key={l.to} to={l.to} className="font-medium transition-colors hover:text-[#ccff00]">
{l.label}
</Link>
))}
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-gray-500">
{t('chromeUi.notSponsoredBy')}
<img src="https://opensats.org/favicon.ico" alt="OpenSats" className="h-3 w-3" />
<span className="font-medium text-gray-400">OpenSats</span>
</span>
<button
onClick={() => setHidden(true)}
aria-label={t('mapUi.mapFooter.hide')}
className="min-h-0 min-w-0 text-gray-500 transition-colors hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Mobile — a tiny chip that expands to a compact row */}
<div className="pointer-events-auto relative mb-1.5 flex justify-center md:hidden">
{open && (
<div className="absolute bottom-full left-1/2 mb-1.5 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-3 overflow-x-auto whitespace-nowrap rounded-full border border-white/10 bg-black/85 px-3 py-1.5 text-[10px] text-gray-300 backdrop-blur scrollbar-hide">
{links.map((l) => (
<Link key={l.to} to={l.to} className="font-medium hover:text-[#ccff00]">
{l.label}
</Link>
))}
<button
onClick={() => setHidden(true)}
aria-label={t('mapUi.mapFooter.hide')}
className="min-h-0 min-w-0 text-gray-500 hover:text-white"
>
<X className="h-3 w-3" />
</button>
</div>
)}
<button
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex min-h-0 items-center gap-1 rounded-full bg-black/45 px-2.5 py-[3px] text-[10px] font-semibold text-gray-400/90 backdrop-blur transition-colors hover:text-gray-200"
>
<ChevronUp className={cn('h-3 w-3 transition-transform', open && 'rotate-180')} />
{t('mapUi.mapFooter.links')}
</button>
</div>
</div>
);
}

View file

@ -0,0 +1,30 @@
import { useCallback, useEffect } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
const KEY = 'skatespots:map-footer-hidden';
const EVENT = 'mapfooter:hiddenchange';
/**
* Whether the thin links strip at the bottom of the map is hidden. Device-local
* and synced within the tab via a custom event (so the Settings toggle and the
* strip stay in step). Restorable from Settings → Appearance.
*/
export function useMapFooterHidden(): [boolean, (value: boolean) => void] {
const [hidden, setHiddenRaw] = useLocalStorage(KEY, false);
useEffect(() => {
const handler = (e: Event) => setHiddenRaw(!!(e as CustomEvent<boolean>).detail);
window.addEventListener(EVENT, handler);
return () => window.removeEventListener(EVENT, handler);
}, [setHiddenRaw]);
const setHidden = useCallback(
(value: boolean) => {
setHiddenRaw(value);
window.dispatchEvent(new CustomEvent(EVENT, { detail: value }));
},
[setHiddenRaw],
);
return [hidden, setHidden];
}

View file

@ -790,6 +790,10 @@ nav: {
},
mapUi: {
mapFooter: {
links: 'Links',
hide: 'Hide map links bar',
},
// Map chrome
findingSpots: 'Finding spots',
loadingSpots: 'Loading spots',
@ -5109,6 +5113,8 @@ settingsUi: {
assistant: 'Assistant',
showSasquatch: 'Show Sasquatch assistant',
showSasquatchDesc: 'Show the floating Sasquatch button on the map and other screens.',
showMapFooter: 'Show map links bar',
showMapFooterDesc: 'Show the thin links strip at the bottom of the map.',
notifications: 'Notifications',
notificationsSub: 'Alerts for DMs, crews and zaps',
coordinator: 'Coordinator',

View file

@ -790,6 +790,10 @@ nav: {
},
mapUi: {
mapFooter: {
links: 'Enlaces',
hide: 'Ocultar barra de enlaces del mapa',
},
// Map chrome
findingSpots: 'Buscando spots',
loadingSpots: 'Cargando spots',
@ -5109,6 +5113,8 @@ settingsUi: {
assistant: 'Asistente',
showSasquatch: 'Mostrar el asistente Sasquatch',
showSasquatchDesc: 'Muestra el botón flotante del Sasquatch en el mapa y otras pantallas.',
showMapFooter: 'Mostrar barra de enlaces del mapa',
showMapFooterDesc: 'Muestra la franja fina de enlaces en la parte inferior del mapa.',
notifications: 'Notificaciones',
notificationsSub: 'Avisos de MD, crews y zaps',
coordinator: 'Coordinador',

View file

@ -5,6 +5,7 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
// Lazy-load the map — Leaflet is large (~150KB) and only needed on this page
const SpotMap = lazy(() => import('@/components/SpotMap').then(m => ({ default: m.SpotMap })));
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { MapFooterStrip } from '@/components/MapFooterStrip';
import { WeatherToggle } from '@/components/WeatherToggle';
import { RouteDialog } from '@/components/RouteDialog';
import { TrustFilter } from '@/components/TrustFilter';
@ -1833,7 +1834,7 @@ export default function MapPage() {
{/* Map attribution — bottom-left, sits ABOVE the mobile bottom
nav so it never covers the Shops & Spots buttons. Tiny + subtle. */}
<div
className="absolute left-1.5 z-[999] pointer-events-none bottom-[calc(5rem+env(safe-area-inset-bottom,0px)+0.25rem)] md:bottom-1.5"
className="absolute left-1.5 z-[999] pointer-events-none bottom-[calc(5rem+env(safe-area-inset-bottom,0px)+0.25rem)] md:bottom-7"
>
<div className="bg-black/55 backdrop-blur-sm px-1.5 py-0.5 rounded text-[9px] font-medium text-gray-200/90 leading-none pointer-events-auto">
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">© OpenStreetMap</a>
@ -1843,6 +1844,10 @@ export default function MapPage() {
</div>
</div>
{/* Slim, dismissible site-links strip — the desktop footer is hidden
on this full-bleed route, so surface the links here instead. */}
<MapFooterStrip />
{/* Location permission help dialog */}
{showLocationHelp && (
<div className="absolute inset-0 z-[2000] flex items-end justify-center pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:items-center md:pb-0 px-4">

View file

@ -3,6 +3,7 @@ import { Link, useSearchParams } from 'react-router-dom';
import { EditProfileForm } from '@/components/EditProfileForm';
import { Switch } from '@/components/ui/switch';
import { useSasquatchHidden } from '@/hooks/useSasquatchHidden';
import { useMapFooterHidden } from '@/hooks/useMapFooterHidden';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { ProfilePreviewCard } from '@/components/ProfilePreviewCard';
import { RelayListManager } from '@/components/RelayListManager';
@ -614,6 +615,7 @@ function RelaysScreen({ onBack }: { onBack: () => void }) {
function AppearanceScreen({ onBack }: { onBack: () => void }) {
const { t } = useTranslation();
const [sasquatchHidden, setSasquatchHidden] = useSasquatchHidden();
const [mapFooterHidden, setMapFooterHidden] = useMapFooterHidden();
return (
<div>
<BackHeader title={t('settingsUi.settingsPage.appearance')} onBack={onBack} />
@ -636,6 +638,13 @@ function AppearanceScreen({ onBack }: { onBack: () => void }) {
</div>
<Switch checked={!sasquatchHidden} onCheckedChange={(v) => setSasquatchHidden(!v)} aria-label={t('settingsUi.settingsPage.showSasquatch')} />
</div>
<div className="mt-2 rounded-2xl border border-white/8 p-4 bg-white/2 flex items-center justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-bold text-white">{t('settingsUi.settingsPage.showMapFooter')}</p>
<p className="text-xs text-muted-foreground">{t('settingsUi.settingsPage.showMapFooterDesc')}</p>
</div>
<Switch checked={!mapFooterHidden} onCheckedChange={(v) => setMapFooterHidden(!v)} aria-label={t('settingsUi.settingsPage.showMapFooter')} />
</div>
</div>
<div className="rounded-2xl border border-white/8 p-5 bg-white/2 text-center">