feat: weekly newsletter system with Issue #001

- NewsletterPage (/newsletter) — zine aesthetic, dark bg, neon accents
  Issue #001 covers all 8 security measures shipped this week:
  geohash validation, URL sanitization, content limits, WoT defaults,
  trusted stats, OfficialBadge, marker clustering, DM phishing warning

- NewsletterOverlay — two overlays rendered globally via Layout:
  1. Floating "Latest Drop" pill — shows for 7 days after each issue,
     dismissible per-issue via localStorage, hides on /newsletter page
  2. Monday admin reminder — only visible to 2 authorized pubkeys
     (founder + second admin), shows every Monday, one-click opens
     Shakespeare AI with prompt pre-filled to write the next issue,
     dismisses for the rest of that Monday via localStorage

- Footer — Newsletter link added to desktop footer alongside License/Supporters

- AppRouter — /newsletter route added (lazy loaded)

To add Issue #002: prepend a new object to NEWSLETTER_ISSUES array
in NewsletterPage.tsx. The pill and Monday reminder auto-update.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-06-18 14:39:54 +07:00
parent f19851da92
commit c146d94b9f
4 changed files with 558 additions and 0 deletions

View file

@ -47,6 +47,7 @@ const SupportersPage = lazy(() => import("./pages/SupportersPage"));
const RiderCardPage = lazy(() => import("./pages/RiderCardPage")); const RiderCardPage = lazy(() => import("./pages/RiderCardPage"));
const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage")); const AmberCallbackPage = lazy(() => import("./pages/AmberCallbackPage"));
const MarketplacePage = lazy(() => import("./pages/MarketplacePage")); const MarketplacePage = lazy(() => import("./pages/MarketplacePage"));
const NewsletterPage = lazy(() => import("./pages/NewsletterPage"));
const NotFound = lazy(() => import("./pages/NotFound")); const NotFound = lazy(() => import("./pages/NotFound"));
// Minimal inline fallback — no extra component needed // Minimal inline fallback — no extra component needed
@ -113,6 +114,8 @@ export function AppRouter() {
<Route path="/amber-callback" element={<AmberCallbackPage />} /> <Route path="/amber-callback" element={<AmberCallbackPage />} />
{/* Marketplace */} {/* Marketplace */}
<Route path="/marketplace-listings" element={<MarketplacePage />} /> <Route path="/marketplace-listings" element={<MarketplacePage />} />
{/* Newsletter */}
<Route path="/newsletter" element={<NewsletterPage />} />
{/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */} {/* NIP-19 route for npub1, note1, naddr1, nevent1, nprofile1 */}
<Route path="/:nip19" element={<NIP19Page />} /> <Route path="/:nip19" element={<NIP19Page />} />
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */} {/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}

View file

@ -52,6 +52,7 @@ import { SetStatusDialog } from '@/components/SetStatusDialog';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { useAuthor } from '@/hooks/useAuthor'; import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName'; import { genUserName } from '@/lib/genUserName';
import { NewsletterOverlay } from '@/components/NewsletterOverlay';
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -763,6 +764,12 @@ export function Layout({ children }: LayoutProps) {
<div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400"> <div className="flex flex-col md:flex-row items-center justify-between gap-4 text-sm text-gray-400">
<span>Made with <span className="text-red-500">❤️</span> for the skateboarding community</span> <span>Made with <span className="text-red-500">❤️</span> for the skateboarding community</span>
<div className="flex items-center gap-4 text-xs text-gray-500"> <div className="flex items-center gap-4 text-xs text-gray-500">
<Link
to="/newsletter"
className="hover:text-[#ccff00] transition-colors font-medium"
>
Newsletter
</Link>
<Link <Link
to="/license" to="/license"
className="hover:text-[#ccff00] transition-colors font-medium" className="hover:text-[#ccff00] transition-colors font-medium"
@ -817,6 +824,9 @@ export function Layout({ children }: LayoutProps) {
open={showStatusDialog} open={showStatusDialog}
onOpenChange={setShowStatusDialog} onOpenChange={setShowStatusDialog}
/> />
{/* Newsletter: floating pill + Monday admin reminder */}
<NewsletterOverlay />
</div> </div>
); );
} }

View file

@ -0,0 +1,173 @@
import { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { X, Radio, Pencil } from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { NEWSLETTER_ISSUES } from '@/pages/NewsletterPage';
// ─── Config ──────────────────────────────────────────────────────────────────
/**
* Only these two pubkeys see the Monday "write newsletter" reminder.
* Everyone else sees nothing.
*/
const NEWSLETTER_ADMIN_PUBKEYS = [
'20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21', // SkateSpots founder — npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', // npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l
];
/**
* How many days the "Latest Drop" pill stays visible after a new issue.
* After this window it auto-hides until the next issue drops.
*/
const PILL_VISIBLE_DAYS = 7;
/**
* Pre-filled Shakespeare chat URL that opens ready to write the next newsletter.
* When you click it on a Monday, it takes you straight into the AI with the
* prompt already typed — just hit send.
*/
const SHAKESPEARE_PROMPT = encodeURIComponent(
'Write this week\'s SkateSpots newsletter issue. Read the git log for the past 7 days, summarize all changes in plain skater language, pick a spot of the week if any new ones were submitted, and format it following the same structure as Issue #001. Keep it short, punchy, no corporate tone.'
);
const WRITE_NEWSLETTER_URL = `https://shakespeare.diy/project/skatespots?prompt=${SHAKESPEARE_PROMPT}`;
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isMonday(): boolean {
return new Date().getDay() === 1;
}
function daysSinceIssue(isoDate: string): number {
const issued = new Date(isoDate).getTime();
const now = Date.now();
return Math.floor((now - issued) / (1000 * 60 * 60 * 24));
}
function formatIssueNumber(n: number): string {
return `#${String(n).padStart(3, '0')}`;
}
// ─── Floating "Latest Drop" pill ─────────────────────────────────────────────
function LatestDropPill() {
const location = useLocation();
const latest = NEWSLETTER_ISSUES[0];
const age = daysSinceIssue(latest.date);
const [dismissed, setDismissed] = useLocalStorage<number>(
'newsletter-pill-dismissed-issue',
0
);
// Hide on newsletter page itself, if too old, or if already dismissed for this issue
if (
location.pathname === '/newsletter' ||
age > PILL_VISIBLE_DAYS ||
dismissed === latest.number
) {
return null;
}
return (
<div className="fixed bottom-[calc(4.5rem+env(safe-area-inset-bottom,0px))] md:bottom-6 right-4 z-40 flex items-center gap-2 animate-in slide-in-from-bottom-4 duration-300">
<Link
to="/newsletter"
className="flex items-center gap-2 pl-3 pr-2 py-2 rounded-full bg-black/95 border border-[#ccff00]/50 shadow-lg shadow-[#ccff00]/10 hover:border-[#ccff00] hover:shadow-[#ccff00]/20 transition-all group"
>
<Radio className="h-3.5 w-3.5 text-[#ccff00] animate-pulse" />
<span className="text-xs font-black text-[#ccff00] uppercase tracking-wide">
{formatIssueNumber(latest.number)} is out
</span>
<span className="text-[10px] text-gray-500 hidden sm:inline pr-1">
{age === 0 ? 'Today' : age === 1 ? 'Yesterday' : `${age}d ago`}
</span>
</Link>
<button
onClick={() => setDismissed(latest.number)}
className="flex items-center justify-center w-7 h-7 rounded-full bg-black/95 border border-white/10 text-gray-500 hover:text-white hover:border-white/30 transition-colors"
aria-label="Dismiss"
>
<X className="h-3 w-3" />
</button>
</div>
);
}
// ─── Monday admin reminder ────────────────────────────────────────────────────
function MondayAdminReminder() {
const { user } = useCurrentUser();
const location = useLocation();
const [dismissed, setDismissed] = useLocalStorage<string>(
'newsletter-monday-dismissed',
''
);
// Today's date key — so it only shows once per Monday, not every page load
const todayKey = new Date().toISOString().slice(0, 10);
const isAdmin = NEWSLETTER_ADMIN_PUBKEYS.includes(user?.pubkey ?? '');
const alreadyDismissedToday = dismissed === todayKey;
if (
!isAdmin ||
!isMonday() ||
alreadyDismissedToday ||
location.pathname === '/newsletter'
) {
return null;
}
return (
<div className="fixed top-20 md:top-20 right-4 z-50 max-w-xs animate-in slide-in-from-right-4 duration-300">
<div className="bg-black/98 border-2 border-[#ccff00]/60 rounded-2xl shadow-2xl shadow-[#ccff00]/10 p-4">
<div className="flex items-start gap-3">
<div className="w-8 h-8 rounded-xl bg-[#ccff00]/15 flex items-center justify-center flex-shrink-0">
<Pencil className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[#ccff00] font-black text-xs uppercase tracking-wide mb-1">
It's Monday
</p>
<p className="text-gray-300 text-xs leading-relaxed mb-3">
Time to drop the weekly newsletter. One click and the AI writes it from the git log.
</p>
<a
href={WRITE_NEWSLETTER_URL}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2 rounded-xl bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 transition-colors"
onClick={() => setDismissed(todayKey)}
>
<Pencil className="h-3.5 w-3.5" />
Write Issue #{String(NEWSLETTER_ISSUES[0].number + 1).padStart(3, '0')}
</a>
</div>
<button
onClick={() => setDismissed(todayKey)}
className="flex-shrink-0 text-gray-600 hover:text-gray-400 transition-colors"
aria-label="Dismiss reminder"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
// ─── Combined overlay export ──────────────────────────────────────────────────
export function NewsletterOverlay() {
const [mounted, setMounted] = useState(false);
// Avoid SSR hydration mismatch — only render client-side
useEffect(() => { setMounted(true); }, []);
if (!mounted) return null;
return (
<>
<LatestDropPill />
<MondayAdminReminder />
</>
);
}

View file

@ -0,0 +1,372 @@
import { useSeoMeta } from '@unhead/react';
import { Layout } from '@/components/Layout';
import { Badge } from '@/components/ui/badge';
import { Link } from 'react-router-dom';
import { Zap, MapPin, Shield, Layers, Crown, Radio, ArrowRight } from 'lucide-react';
// ─── Newsletter data ──────────────────────────────────────────────────────────
// To add a new issue, prepend an object to this array.
// The first item in the array is always the latest issue (shown in the pill/banner).
export interface NewsletterIssue {
/** Issue number shown as #001 */
number: number;
/** ISO date string e.g. "2026-06-18" */
date: string;
/** Short punchy vibe line — shown as subtitle */
vibe: string;
/** Full rendered content — JSX */
content: React.ReactNode;
}
export const NEWSLETTER_ISSUES: NewsletterIssue[] = [
{
number: 1,
date: '2026-06-18',
vibe: 'Security stack, clustering, and a DM warning that\'ll save someone\'s keys.',
content: <Issue001 />,
},
];
// ─── Issue #001 ───────────────────────────────────────────────────────────────
function Issue001() {
return (
<div className="space-y-10">
{/* WHAT'S NEW */}
<section>
<SectionHeader emoji="🛠" label="What's New" color="#ccff00" />
<div className="space-y-4 mt-4">
<UpdateCard
icon={<Shield className="h-4 w-4 text-green-400" />}
title="Fake spot protection"
tag="Security"
tagColor="green"
>
The app now validates every geohash before accepting a spot. Minimum 4 characters of
precision (anything less is just "somewhere in Spain"), real Earth coordinates only,
and we specifically block 0°N 0°E — a spot in the middle of the Atlantic Ocean that
bots love to use. VPN and Tor users are completely unaffected: the pin comes from
where you drag it on the map, not from your IP.
</UpdateCard>
<UpdateCard
icon={<Shield className="h-4 w-4 text-blue-400" />}
title="URL sanitization on shops and profiles"
tag="Security"
tagColor="blue"
>
Shop websites, coach profiles, and filmer pages now run through a sanitizer before
rendering as links. Only <code className="bg-white/10 px-1 rounded text-xs">https://</code> and{' '}
<code className="bg-white/10 px-1 rounded text-xs">http://</code> get through.{' '}
<code className="bg-white/10 px-1 rounded text-xs">javascript:</code>,{' '}
<code className="bg-white/10 px-1 rounded text-xs">data:</code>, and anything else
silently drops the link. Zero friction for real websites — every legit URL starts
with https.
</UpdateCard>
<UpdateCard
icon={<Crown className="h-4 w-4 text-yellow-400" />}
title="Official badge"
tag="Anti-impersonation"
tagColor="yellow"
>
Spots, shops, events, and coach profiles published by the verified SkateSpots founder
pubkey now show a small <span className="text-yellow-400 font-bold">👑 Official</span> badge
next to the author name. If someone tries to create a fake "SkateSpots Official" account
and publish content under a different key, there's no badge. Immediately obvious.
</UpdateCard>
<UpdateCard
icon={<Layers className="h-4 w-4 text-[#ff0040]" />}
title="Map marker clustering"
tag="UX"
tagColor="red"
>
When you zoom out and there are many spots close together, they now group into a numbered
circle (e.g. "34") instead of a pile of overlapping pins. Zoom in or click the cluster
and they spread apart naturally. Makes cities like Barcelona, NYC, or LA actually
navigable at city-level zoom. Also means a spam flood of fake spots can't make the map
unusable.
</UpdateCard>
<UpdateCard
icon={<Radio className="h-4 w-4 text-purple-400" />}
title="Content length limits in all parsers"
tag="Spam defense"
tagColor="purple"
>
Every spot, shop, coach, filmer, and editor profile now has hard limits on title
(100 chars), description (3000 chars), and location (200 chars). Events too.
Anything over that gets trimmed silently. In practice this only affects bots pushing
multi-megabyte injection payloads — no real skater writes a 3001-character spot
description.
</UpdateCard>
<UpdateCard
icon={<Zap className="h-4 w-4 text-[#ccff00]" />}
title="Trusted count on community stats"
tag="Transparency"
tagColor="lime"
>
The <Link to="/stats" className="text-[#ccff00] underline underline-offset-2">Community Stats</Link> page
now shows two numbers alongside the spot count: total spots, and spots from
"active accounts" — meaning pubkeys that have published at least 2 items in the
community. Sponsors and curious visitors can now see both the raw number and the
organic number side by side.
</UpdateCard>
<UpdateCard
icon={<Shield className="h-4 w-4 text-[#ccff00]" />}
title="DM phishing warning"
tag="Protection"
tagColor="lime"
>
First time you open the messages page there's now a small dismissible banner:
"SkateSpots will never DM you asking to verify your account, share your keys, or send
payments." One tap to dismiss, never shows again. Standard practice across every serious
Bitcoin/Nostr app. Protects new users who don't yet know this is how social engineering
attacks work.
</UpdateCard>
<UpdateCard
icon={<MapPin className="h-4 w-4 text-[#ff6600]" />}
title="WoT trust filter — smart defaults"
tag="Feed quality"
tagColor="orange"
>
The trust filter on the map and events page now sets its default based on context.
If you're logged out or new with fewer than 5 follows, you see everything — we don't
treat new people like suspects. If you're logged in with a proper follow list, trusted
content floats to the top automatically. You can always flip to "All" with one tap.
Nothing is ever hidden by default.
</UpdateCard>
</div>
</section>
{/* SPOT OF THE WEEK */}
<section>
<SectionHeader emoji="📍" label="Spot of the Week" color="#ff0040" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff0040]/30 bg-[#ff0040]/5">
<p className="text-gray-400 text-sm italic">
No community spot selected yet for Issue #001 — this is where we'll highlight one
spot submitted by the community each week. Photo, location, who mapped it, why it
rips.
</p>
<Link
to="/map"
className="inline-flex items-center gap-2 mt-3 text-[#ccff00] text-sm font-bold hover:opacity-80 transition-opacity"
>
Browse the map and add yours
<ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
</section>
{/* COMMUNITY */}
<section>
<SectionHeader emoji="👥" label="Community" color="#ff6600" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#ff6600]/30 bg-[#ff6600]/5">
<p className="text-gray-400 text-sm">
The relay is live at <code className="bg-white/10 px-1 rounded text-xs">wss://goskatespots.nostr1.com</code>.
Spots, shops, events, coaches, filmers, editors and tours are all flowing through it.
Add it to your Nostr client if you want to follow the community on a broader level.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Link to="/lab">
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border border-[#ff0040]/40 hover:bg-[#ff0040]/30 cursor-pointer">
The Lab
</Badge>
</Link>
<Link to="/events">
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border border-[#ccff00]/40 hover:bg-[#ccff00]/30 cursor-pointer">
Events
</Badge>
</Link>
<Link to="/map">
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border border-[#ff6600]/40 hover:bg-[#ff6600]/30 cursor-pointer">
Spot Map
</Badge>
</Link>
</div>
</div>
</section>
{/* SUPPORT */}
<section>
<SectionHeader emoji="⚡" label="Support the Project" color="#f7931a" />
<div className="mt-4 p-5 rounded-2xl border-2 border-[#f7931a]/30 bg-[#f7931a]/5">
<p className="text-sm text-gray-300 mb-3">
SkateSpots is 100% open source, community funded, no ads, no investors. If it's
useful to you, zap the project or contribute on Geyser.
</p>
<a
href="https://geyser.fund/project/skatespots"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[#f7931a] text-sm font-bold hover:opacity-80 transition-opacity"
>
<Zap className="h-4 w-4" />
geyser.fund/project/skatespots
<ArrowRight className="h-3.5 w-3.5" />
</a>
</div>
</section>
</div>
);
}
// ─── Shared sub-components ────────────────────────────────────────────────────
function SectionHeader({ emoji, label, color }: { emoji: string; label: string; color: string }) {
return (
<div className="flex items-center gap-3">
<span className="text-xl">{emoji}</span>
<h2
className="text-xs font-black uppercase tracking-widest"
style={{ color }}
>
{label}
</h2>
<div className="flex-1 h-px" style={{ background: `${color}30` }} />
</div>
);
}
const TAG_COLORS: Record<string, string> = {
green: 'bg-green-500/15 text-green-400 border-green-500/30',
blue: 'bg-blue-500/15 text-blue-400 border-blue-500/30',
yellow: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
red: 'bg-[#ff0040]/15 text-[#ff0040] border-[#ff0040]/30',
purple: 'bg-purple-500/15 text-purple-400 border-purple-500/30',
lime: 'bg-[#ccff00]/15 text-[#ccff00] border-[#ccff00]/30',
orange: 'bg-[#ff6600]/15 text-[#ff6600] border-[#ff6600]/30',
};
function UpdateCard({
icon,
title,
tag,
tagColor,
children,
}: {
icon: React.ReactNode;
title: string;
tag: string;
tagColor: string;
children: React.ReactNode;
}) {
return (
<div className="p-4 rounded-xl border border-white/8 bg-white/3 hover:bg-white/5 transition-colors">
<div className="flex items-start gap-3 mb-2">
<div className="mt-0.5 flex-shrink-0">{icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap mb-1">
<h3 className="font-bold text-white text-sm">{title}</h3>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold border ${TAG_COLORS[tagColor] ?? TAG_COLORS.lime}`}>
{tag}
</span>
</div>
<p className="text-gray-400 text-sm leading-relaxed">{children}</p>
</div>
</div>
</div>
);
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function formatIssueDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export default function NewsletterPage() {
useSeoMeta({
title: 'Newsletter - SkateSpots',
description: 'Weekly drops from the SkateSpots community. App updates, new spots, events and the people keeping skateboarding alive.',
});
const latest = NEWSLETTER_ISSUES[0];
return (
<Layout>
<div className="container max-w-2xl px-4 py-10 md:py-14">
{/* ── Masthead ── */}
<div className="mb-12 text-center">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#ccff00]/30 bg-[#ccff00]/5 text-[10px] font-black uppercase tracking-widest text-[#ccff00] mb-5">
<Radio className="h-3 w-3" />
Weekly Drop
</div>
<h1 className="text-5xl sm:text-6xl font-black uppercase leading-none mb-3"
style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
<span className="bg-gradient-to-r from-[#ff0040] via-[#ff6600] to-[#ccff00] bg-clip-text text-transparent">
SKATE
</span>
<br />
<span className="text-white">SPOTS</span>
</h1>
<p className="text-gray-500 text-xs uppercase tracking-widest">
The community newsletter
</p>
</div>
{/* ── Issues ── */}
<div className="space-y-16">
{NEWSLETTER_ISSUES.map((issue) => (
<article key={issue.number}>
{/* Issue header */}
<div className="flex items-start justify-between gap-4 mb-8 pb-6 border-b border-white/8">
<div>
<div className="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">
{formatIssueDate(issue.date)}
</div>
<h2 className="text-3xl font-black uppercase leading-none text-white">
#{String(issue.number).padStart(3, '0')}
</h2>
<p className="text-gray-400 text-sm mt-2 max-w-md">
{issue.vibe}
</p>
</div>
<div className="flex-shrink-0">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] flex items-center justify-center shadow-lg shadow-[#ff0040]/30">
<span className="text-white font-black text-lg" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
#{String(issue.number).padStart(3, '0')}
</span>
</div>
</div>
</div>
{/* Issue body */}
{issue.content}
</article>
))}
</div>
{/* ── Footer blurb ── */}
<div className="mt-16 pt-8 border-t border-white/8 text-center">
<p className="text-xs text-gray-600 mb-2">
Built on <a href="https://nostr.com" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">Nostr</a>
{' · '}
<Link to="/license" className="text-gray-500 hover:text-[#ccff00] transition-colors">Open source</Link>
{' · '}
<a href="https://geyser.fund/project/skatespots" target="_blank" rel="noopener noreferrer" className="text-gray-500 hover:text-[#ccff00] transition-colors">Support us</a>
</p>
<p className="text-[10px] text-gray-700 uppercase tracking-widest">
You are free to skateboard
</p>
</div>
</div>
{/* Latest issue pill and Monday reminder are rendered globally via Layout */}
</Layout>
);
}