feat(newsletter): weekly generated drafts, covers, and admin preview

- Add a browser-safe editorial generator with rotating satire and shipped highlights.
- Generate a fresh 1200x630 cover from the Drop #006 foundation and upload it to Blossom.
- Publish weekly drafts as NIP-78; add the admin Load draft/Preview composer flow and banner.
- Add the Monday user timer (draft by default) and a manual newsletter:weekly script.
- Remove static/live issue duplicates; translate the new admin strings to all 24 locales.
This commit is contained in:
SkateSpots 2026-10-28 09:42:44 +07:00
parent dfff2a8c40
commit f029b5a516
39 changed files with 999 additions and 12 deletions

View file

@ -870,6 +870,23 @@ session with **no other memory of this project**. So:
hook do this and fail loudly if every relay rejects. `useSpotChat` is the
reference pattern. (Before the fix, an admin whose own NIP-65 lacked
`goskatespots` would publish issues nobody could find.)
- **Weekly newsletter automation (2026-10-28).** `scripts/newsletter-weekly.ts`
generates the next issue (published max + 1), builds all fields, makes a
fresh 1200×630 cover from the Drop #006 foundation, uploads it to Blossom,
and publishes **only a draft** as NIP-78 `kind 30078`,
`d=skatespots:newsletter:draft`, `t=skatespotsnewsdraft`. The admin
`/newsletter` page shows a **Weekly draft ready** banner; the composer loads
it (`useNewsletterDraft`) and has a safe Markdown **Preview**. Set
`NEWSLETTER_AUTOPUBLISH=1` in the service to also publish the NIP-23 issue +
kind-1 teaser. The timer (`scripts/systemd/skatespots-newsletter.timer`,
Monday 08:00, install with `scripts/install-newsletter-timer.sh`) defaults to
`NEWSLETTER_AUTOPUBLISH=0` — review the voice/cover before enabling. The
editorial bank lives in `src/lib/newsletterContent.ts` (rotating South Park
jokes; 4 shipped highlights; current tech wires); issue 7 uses the curated
highlights, later issues parse the top of `release-notes.txt`. The static
archive now hides an issue number that is also live, so backfilled issues
don't duplicate. The draft event is public (it contains no private data);
never put `SIGN_WITH` in it or in tracked files.
- **Nav parity (fixed 2026-09-27):** the desktop header is `max-w-[1700px]`
(wider than the page `container`'s 1400px) and nav items are `px-3` +
`whitespace-nowrap`; the nav keeps `min-w-0 overflow-x-auto scrollbar-hide`, so

View file

@ -26,7 +26,8 @@
"android:open": "npx cap open android",
"android:version": "node scripts/sync-android-version.mjs",
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && node scripts/inject-network-security.mjs && node scripts/inject-signing-config.mjs && cd android && ./gradlew assembleRelease",
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html",
"newsletter:weekly": "tsx scripts/newsletter-weekly.ts"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.2.0",

View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Install the weekly newsletter draft timer (user systemd).
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p "$HOME/.config/systemd/user"
cp scripts/systemd/skatespots-newsletter.service scripts/systemd/skatespots-newsletter.timer "$HOME/.config/systemd/user/"
systemctl --user daemon-reload
systemctl --user enable --now skatespots-newsletter.timer
systemctl --user list-timers skatespots-newsletter.timer --no-pager

134
scripts/newsletter-cover.ts Normal file
View file

@ -0,0 +1,134 @@
/**
* Weekly newsletter cover generator.
*
* Uses the existing Drop #006 cover as the visual foundation: the same black /
* orange palette, the urban sunset photography and the route-line motif. Only
* the issue number, headline, date and copy change. Output is 1200×630 PNG.
*/
import { join } from 'node:path';
import sharp from 'sharp';
const SOURCE = join(process.cwd(), 'public/images/drop-006-cover.png');
function esc(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function lines(text: string, maxChars: number, maxLines = 3): string[] {
const words = text.toUpperCase().split(/\s+/);
const out: string[] = [];
let current = '';
for (const word of words) {
const next = current ? `${current} ${word}` : word;
if (next.length > maxChars && current) {
out.push(current);
current = word;
if (out.length === maxLines - 1) break;
} else {
current = next;
}
}
if (current && out.length < maxLines) out.push(current);
return out;
}
export async function generateNewsletterCover(params: {
number: number;
dateLabel: string;
coverTitle: string;
outputPath: string;
}): Promise<string> {
const { number, dateLabel, coverTitle, outputPath } = params;
const issue = `#${String(number).padStart(3, '0')}`;
const titleLines = lines(coverTitle, 15, 3);
const source = sharp(SOURCE);
const meta = await source.metadata();
const width = meta.width ?? 1059;
const height = meta.height ?? 537;
// Take the city/sunset side of the old cover, away from the big old headline.
const cropWidth = Math.max(320, Math.min(width - 640, 520));
const hero = await sharp(SOURCE)
.extract({
left: Math.max(0, width - cropWidth - 20),
top: Math.max(0, Math.floor(height * 0.2)),
width: cropWidth,
height: Math.floor(height * 0.62),
})
.resize(560, 340, { fit: 'cover' })
.modulate({ brightness: 0.82, saturation: 0.9 })
.toBuffer();
const titleSvg = titleLines
.map(
(line, i) =>
`<text x="56" y="${272 + i * 76}" font-family="DejaVu Sans, sans-serif" font-size="62" font-weight="900" fill="#ffffff">${esc(line)}</text>`,
)
.join('');
const overlay = `
<svg width="1200" height="630" viewBox="0 0 1200 630" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="fade" x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stop-color="#080808" stop-opacity="1"/>
<stop offset="0.58" stop-color="#080808" stop-opacity="0.35"/>
<stop offset="1" stop-color="#080808" stop-opacity="0.08"/>
</linearGradient>
<linearGradient id="bottom" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#080808" stop-opacity="0"/>
<stop offset="1" stop-color="#080808" stop-opacity="0.92"/>
</linearGradient>
</defs>
<rect width="1200" height="630" fill="url(#fade)"/>
<rect x="560" y="120" width="600" height="380" fill="url(#bottom)"/>
<g font-family="DejaVu Sans, sans-serif">
<text x="56" y="62" font-size="13" font-weight="700" letter-spacing="4" fill="#ff5a1f">SKATESPOTS</text>
<text x="56" y="88" font-size="30" font-weight="900" fill="#ffffff">NEWSLETTER</text>
<text x="760" y="62" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL SPOTS</text>
<text x="760" y="82" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL PEOPLE</text>
<text x="760" y="102" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">REAL SKATEBOARDING</text>
<rect x="978" y="42" width="168" height="40" rx="20" fill="none" stroke="#ff5a1f" stroke-width="2"/>
<text x="1062" y="68" text-anchor="middle" font-size="14" font-weight="900" fill="#ff5a1f">DROP ${issue}</text>
<text x="56" y="160" font-size="12" font-weight="700" letter-spacing="4" fill="#ff5a1f">THIS WEEK</text>
<text x="56" y="190" font-size="13" font-weight="700" fill="#a3a3a3">${esc(dateLabel)}</text>
${titleSvg}
<line x1="56" y1="510" x2="520" y2="510" stroke="#ff5a1f" stroke-width="3"/>
<text x="56" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">SPOTS</text>
<text x="160" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">SHOPS</text>
<text x="270" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">PEOPLE</text>
<text x="390" y="548" font-size="12" font-weight="700" letter-spacing="2" fill="#bdbdbd">MORE SPOTS</text>
<polyline points="58,530 115,530 170,500 235,515 300,490 375,505 445,470 510,480" fill="none" stroke="#ff5a1f" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="58" cy="530" r="6" fill="#ff5a1f"/>
<circle cx="510" cy="480" r="6" fill="#ff5a1f"/>
<text x="1144" y="590" text-anchor="end" font-size="11" font-weight="700" fill="#737373">skate-spots.org/newsletter</text>
</g>
</svg>`;
const svgBuffer = Buffer.from(overlay);
await sharp({
create: { width: 1200, height: 630, channels: 4, background: '#080808' },
})
.composite([
{ input: hero, left: 620, top: 130, blend: 'over' },
{ input: svgBuffer, left: 0, top: 0, blend: 'over' },
])
.png({ quality: 92 })
.toFile(outputPath);
return outputPath;
}
if (process.argv[1]?.endsWith('newsletter-cover.ts')) {
const out = process.argv[2] ?? '/tmp/opencode/newsletter-cover.png';
await generateNewsletterCover({
number: 7,
dateLabel: '28 OCTOBER 2026',
coverTitle: 'THE PRIVATE CHAT GROWS UP',
outputPath: out,
});
console.log(out);
}

10
scripts/newsletter-weekly.sh Executable file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Weekly newsletter draft generator. Runs from the local repo so it can use the
# admin signer in ~/.config/skatespots/zsp.env and the git/release-notes context.
set -euo pipefail
cd "$(dirname "$0")/.."
if [ -d "$HOME/.nvm/versions/node" ]; then
NODE_BIN="$(ls -d "$HOME"/.nvm/versions/node/*/bin 2>/dev/null | sort -V | tail -1)"
[ -n "$NODE_BIN" ] && export PATH="$NODE_BIN:$PATH"
fi
exec npx tsx scripts/newsletter-weekly.ts

View file

@ -0,0 +1,274 @@
/**
* Weekly SkateSpots newsletter generator.
*
* - Picks the next issue number (latest published + 1).
* - Builds the full draft: title/slug/summary/cover/body/teaser.
* - Generates a fresh cover from the Drop #006 foundation and uploads it to Blossom.
* - Publishes a NIP-78 draft (kind 30078, d=skatespots:newsletter:draft) for the
* admin composer. Set NEWSLETTER_AUTOPUBLISH=1 to publish the NIP-23 issue and
* kind-1 teaser in the same run.
*
* Run: npx tsx scripts/newsletter-weekly.ts
* Signer: SIGN_WITH in ~/.config/skatespots/zsp.env (nsec; never printed).
*/
import { readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import WebSocket from 'ws';
import { NRelay1 } from '@nostrify/nostrify';
import { BlossomUploader } from '@nostrify/nostrify/uploaders';
import { finalizeEvent, nip19 } from 'nostr-tools';
import { getPublicKey } from 'nostr-tools/pure';
import { ADMIN_PUBKEYS } from '../src/lib/admins';
import { NEWSLETTER_ISSUES } from '../src/lib/newsletterIssues';
import {
buildWeeklyDraft,
pickJokes,
WEEKLY_HIGHLIGHTS,
type WeeklyHeadline,
type WeeklyHighlight,
} from '../src/lib/newsletterContent';
import { generateNewsletterCover } from './newsletter-cover';
(globalThis as unknown as { WebSocket?: unknown }).WebSocket = WebSocket;
const RELAY = 'wss://relay.skate-spots.org';
const COVER_TITLES = [
'THE PRIVATE CHAT GROWS UP',
'SHIP HAPPENS',
'THE MAP IS THE POINT',
'BUILT FOR THE PARK',
'LESS SLOP, MORE SKATE',
'DO THE BORING PART',
];
function readSecret(): { sk: Uint8Array; pubkey: string } {
const file = join(process.env.HOME ?? '', '.config/skatespots/zsp.env');
const raw = process.env.SIGN_WITH || readFileSync(file, 'utf8').match(/^SIGN_WITH=(.*)$/m)?.[1]?.trim() || '';
if (!raw.startsWith('nsec1')) throw new Error('newsletter signer must be a local nsec (SIGN_WITH)');
const sk = nip19.decode(raw).data as Uint8Array;
const pubkey = getPublicKey(sk);
if (!ADMIN_PUBKEYS.includes(pubkey)) throw new Error('newsletter signer is not an app admin');
return { sk, pubkey };
}
function parseReleaseHighlights(): WeeklyHighlight[] {
try {
const notes = readFileSync(join(process.cwd(), 'release-notes.txt'), 'utf8');
const top = notes.split(/\n---\n/)[0] ?? '';
const lines = top.split('\n');
let heading = 'Shipped';
let tag = 'App';
const groups: Array<{ heading: string; bullets: string[] }> = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('- ')) {
if (trimmed.startsWith('- ')) {
if (groups.length === 0) groups.push({ heading, bullets: [] });
groups[groups.length - 1].bullets.push(trimmed.slice(2));
}
continue;
}
const clean = trimmed.replace(/^[^\p{L}\p{N}]+/u, '').trim();
if (clean) {
heading = clean;
tag = clean.split(':')[0].slice(0, 24);
groups.push({ heading, bullets: [] });
}
}
const out: WeeklyHighlight[] = [];
for (const group of groups) {
for (const bullet of group.bullets) {
out.push({
tag,
title: bullet.split('—')[0].split('.')[0].slice(0, 90),
body: bullet,
});
if (out.length >= 4) return out;
}
}
return out;
} catch {
return [];
}
}
function decodeXml(value: string): string {
return value
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
async function fetchHeadlines(): Promise<WeeklyHeadline[]> {
const feeds = [
{ url: 'https://news.ycombinator.com/rss', source: 'Hacker News' },
{ url: 'https://techcrunch.com/feed/', source: 'TechCrunch' },
{ url: 'https://www.coindesk.com/arc/outboundfeeds/rss/', source: 'CoinDesk' },
];
const out: WeeklyHeadline[] = [];
for (const feed of feeds) {
try {
const res = await fetch(feed.url, {
headers: { 'user-agent': 'SkateSpotsNewsletter/1.0' },
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) continue;
const xml = await res.text();
const item = xml.match(/<item>([\s\S]*?)<\/item>/i)?.[1];
if (!item) continue;
const title = decodeXml(item.match(/<title>([\s\S]*?)<\/title>/i)?.[1]?.trim() ?? '');
const link = decodeXml(item.match(/<link>([\s\S]*?)<\/link>/i)?.[1]?.trim() ?? '');
if (title && /^https?:\/\//.test(link)) out.push({ title, url: link, source: feed.source });
} catch {
// Feed unavailable — keep the newsletter self-contained.
}
}
return out.slice(0, 3);
}
async function latestIssueNumber(relay: NRelay1): Promise<number> {
const staticMax = Math.max(0, ...NEWSLETTER_ISSUES.map((issue) => issue.number));
try {
const events = await relay.query(
[{ kinds: [30023], authors: ADMIN_PUBKEYS, '#t': ['skatespotsnews'], limit: 50 }],
{ signal: AbortSignal.timeout(10_000) },
);
let max = staticMax;
for (const event of events) {
const title = event.tags.find(([name]) => name === 'title')?.[1] ?? '';
const match = title.match(/#0*(\d+)/);
if (match) max = Math.max(max, Number(match[1]));
}
return max;
} catch {
return staticMax;
}
}
const { sk, pubkey } = readSecret();
const relay = new NRelay1(RELAY);
const number = (await latestIssueNumber(relay)) + 1;
const dateIso = new Date().toISOString().slice(0, 10);
const dateLabel = new Date(`${dateIso}T12:00:00Z`)
.toLocaleDateString('en-GB', { day: '2-digit', month: 'long', year: 'numeric' })
.toUpperCase();
const parsed = number === 7 ? [] : parseReleaseHighlights();
const highlights = parsed.length >= 3 ? parsed : WEEKLY_HIGHLIGHTS;
const jokes = pickJokes(number, 3);
const headlines = await fetchHeadlines();
const coverTitle = COVER_TITLES[(number - 1) % COVER_TITLES.length];
const tempDir = mkdtempSync(join(tmpdir(), 'skatespots-newsletter-'));
const coverPath = join(tempDir, `drop-${String(number).padStart(3, '0')}-cover.png`);
await generateNewsletterCover({ number, dateLabel, coverTitle, outputPath: coverPath });
const signer = {
getPublicKey: async () => pubkey,
signEvent: async (event: Parameters<typeof finalizeEvent>[0]) => finalizeEvent(event, sk),
};
let coverUrl = 'https://skate-spots.org/images/drop-006-cover.png';
try {
const uploader = new BlossomUploader({ servers: ['https://blossom.skate-spots.org/'], signer });
const tags = await uploader.upload(
new File([readFileSync(coverPath)], `drop-${String(number).padStart(3, '0')}-cover.png`, {
type: 'image/png',
}),
);
coverUrl = tags.find(([name]) => name === 'url')?.[1] ?? coverUrl;
} catch (error) {
console.warn('[newsletter] cover upload failed, using the foundation cover:', error instanceof Error ? error.message : error);
}
const draft = buildWeeklyDraft({
number,
dateIso,
coverImage: coverUrl,
coverTitle,
highlights,
headlines,
jokes,
});
const draftEvent = finalizeEvent(
{
kind: 30078,
content: JSON.stringify(draft),
created_at: Math.floor(Date.now() / 1000),
tags: [
['d', 'skatespots:newsletter:draft'],
['t', 'skatespotsnewsdraft'],
['alt', `SkateSpots weekly newsletter draft #${String(number).padStart(3, '0')}`],
],
},
sk,
);
await relay.event(draftEvent, { signal: AbortSignal.timeout(15_000) });
console.log(
JSON.stringify(
{
issue: number,
title: draft.title,
date: dateIso,
cover: coverUrl,
highlights: highlights.length,
jokes: jokes.map((joke) => joke.id),
headlines: headlines.map((headline) => headline.source),
draftEvent: draftEvent.id,
autoPublished: process.env.NEWSLETTER_AUTOPUBLISH === '1',
},
null,
2,
),
);
if (process.env.NEWSLETTER_AUTOPUBLISH === '1') {
const now = Math.floor(Date.now() / 1000);
const issueEvent = finalizeEvent(
{
kind: 30023,
content: draft.body,
created_at: now,
tags: [
['d', draft.slug],
['title', draft.title],
['summary', draft.summary],
['image', draft.image],
['published_at', String(now)],
['t', 'skatespotsnews'],
['t', 'skatespots'],
['t', 'newsletter'],
['t', 'skateboarding'],
['alt', `SkateSpots newsletter issue: ${draft.title}`],
],
},
sk,
);
await relay.event(issueEvent, { signal: AbortSignal.timeout(15_000) });
const teaser = finalizeEvent(
{
kind: 1,
content: draft.nostrText,
created_at: now,
tags: [
['t', 'skatespotsnews'],
['t', 'skateboarding'],
['t', 'nostr'],
],
},
sk,
);
await relay.event(teaser, { signal: AbortSignal.timeout(15_000) });
console.log(JSON.stringify({ publishedIssue: issueEvent.id, teaser: teaser.id }));
}
try {
await relay.close?.();
} catch {
// ignore
}
rmSync(tempDir, { recursive: true, force: true });

View file

@ -0,0 +1,12 @@
[Unit]
Description=Generate the weekly SkateSpots newsletter draft
Documentation=file:///home/sovereignsk8/SkateSpots/scripts/newsletter-weekly.ts
[Service]
Type=oneshot
WorkingDirectory=/home/sovereignsk8/SkateSpots
ExecStart=/home/sovereignsk8/SkateSpots/scripts/newsletter-weekly.sh
# Draft by default. Set NEWSLETTER_AUTOPUBLISH=1 in a drop-in only after the
# admin has reviewed the voice/cover for a few weeks.
Environment=NEWSLETTER_AUTOPUBLISH=0
TimeoutStartSec=20min

View file

@ -0,0 +1,10 @@
[Unit]
Description=Weekly SkateSpots newsletter draft
[Timer]
OnCalendar=Mon 08:00
Persistent=true
RandomizedDelaySec=900
[Install]
WantedBy=timers.target

View file

@ -1,7 +1,7 @@
import { useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useQueryClient } from '@tanstack/react-query';
import { PenLine, Send, Loader2, Sparkles } from 'lucide-react';
import { Eye, EyeOff, PenLine, Send, Loader2, Sparkles } from 'lucide-react';
import {
Dialog,
DialogContent,
@ -19,6 +19,8 @@ import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { isAdminPubkey } from '@/lib/admins';
import { useNewsletterDraft } from '@/hooks/useNewsletterDraft';
import { NewsletterMarkdown } from '@/components/NewsletterIssueView';
import { NEWSLETTER_KIND, NEWSLETTER_TAG } from '@/lib/types';
/** Slugify a title into a stable `d` identifier. */
@ -35,23 +37,50 @@ function slugify(input: string): string {
* article (kind 30023) tagged `skatespotsnews`. Subscribers then receive it in
* their notification bell's "News" tab and it appears on the /newsletter page.
*/
export function NewsletterComposer() {
export function NewsletterComposer({
triggerLabel,
autoLoadDraft = false,
}: {
triggerLabel?: string;
autoLoadDraft?: boolean;
} = {}) {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays();
const { toast } = useToast();
const queryClient = useQueryClient();
const isAdmin = isAdminPubkey(user?.pubkey);
const { data: weeklyDraft } = useNewsletterDraft(isAdmin);
const loadedDraftRef = useRef<number | null>(null);
const [isPending, setIsPending] = useState(false);
const [open, setOpen] = useState(false);
const [preview, setPreview] = useState(false);
const [title, setTitle] = useState('');
const [slug, setSlug] = useState('');
const [summary, setSummary] = useState('');
const [image, setImage] = useState('');
const [body, setBody] = useState('');
if (!isAdminPubkey(user?.pubkey)) return null;
const fillDraft = useCallback(() => {
if (!weeklyDraft) return;
setTitle(weeklyDraft.title);
setSlug(weeklyDraft.slug);
setSummary(weeklyDraft.summary);
setImage(weeklyDraft.image);
setBody(weeklyDraft.body);
setPreview(false);
}, [weeklyDraft]);
useEffect(() => {
if (!open || !autoLoadDraft || !weeklyDraft) return;
if (loadedDraftRef.current === weeklyDraft.createdAt) return;
loadedDraftRef.current = weeklyDraft.createdAt;
fillDraft();
}, [open, autoLoadDraft, weeklyDraft, fillDraft]);
if (!isAdmin) return null;
// A manual slug is used verbatim (lets an admin intentionally EDIT/replace a
// specific existing issue). When left blank we must NOT use the bare
@ -131,7 +160,7 @@ export function NewsletterComposer() {
variant="outline"
className="gap-2 border-[#ccff00]/40 text-[#ccff00] hover:bg-[#ccff00]/10"
>
<PenLine className="h-4 w-4" /> {t('walletUi.newsletter.newIssue')}
<PenLine className="h-4 w-4" /> {triggerLabel ?? t('walletUi.newsletter.newIssue')}
</Button>
</DialogTrigger>
<DialogContent className="max-w-lg w-[calc(100vw-2rem)] bg-black border-2 border-[#ccff00]/30">
@ -145,6 +174,20 @@ export function NewsletterComposer() {
</DialogHeader>
<div className="space-y-3">
{weeklyDraft && (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-[#ccff00]/25 bg-[#ccff00]/5 px-3 py-2">
<div className="min-w-0">
<p className="text-xs font-bold text-[#ccff00]">{t('newsletterDraftUi.weeklyDraftReady')}</p>
<p className="truncate text-[11px] text-gray-500">
{weeklyDraft.title} · {new Date(weeklyDraft.createdAt * 1000).toLocaleDateString()}
</p>
</div>
<Button type="button" size="sm" variant="outline" onClick={fillDraft} className="h-8 gap-1.5 border-[#ccff00]/30 text-[#ccff00]">
<Sparkles className="h-3 w-3" /> {t('newsletterDraftUi.loadDraft')}
</Button>
</div>
)}
<div>
<Label className="text-xs text-gray-400">{t('walletUi.newsletter.fieldTitle')}</Label>
<Input
@ -201,7 +244,30 @@ export function NewsletterComposer() {
placeholder={'## What shipped\n\n- New thing one\n- New thing two\n\nRead more at **skate-spots.org**.'}
className="mt-1 min-h-[200px] font-mono text-sm leading-relaxed"
/>
<p className="mt-1 text-right text-[10px] text-gray-600">{t('walletUi.newsletter.characters', { count: body.length })}</p>
<div className="mt-1 flex items-center justify-between gap-2">
<p className="text-[10px] text-gray-600">{t('walletUi.newsletter.characters', { count: body.length })}</p>
<Button
type="button"
size="sm"
variant="ghost"
className="h-7 gap-1 px-2 text-[11px]"
onClick={() => setPreview((value) => !value)}
disabled={body.trim().length < 10}
>
{preview ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
{preview ? t('newsletterDraftUi.hidePreview') : t('walletUi.newsletter.previewLabel')}
</Button>
</div>
{preview && (
<div className="mt-2 max-h-72 overflow-y-auto rounded-xl border border-white/10 bg-black/40 p-3">
{image && (
<img src={image} alt="" className="mb-3 aspect-video w-full rounded-lg object-cover" />
)}
<h3 className="mb-1 text-lg font-black uppercase text-white">{title || t('newsletterDraftUi.untitled')}</h3>
{summary && <p className="mb-3 text-sm text-gray-400">{summary}</p>}
<NewsletterMarkdown content={body} className="text-sm" />
</div>
)}
</div>
<Button

View file

@ -101,6 +101,11 @@ function renderMarkdown(md: string) {
return blocks;
}
/** Reusable, safe Markdown renderer for newsletter drafts and published issues. */
export function NewsletterMarkdown({ content, className }: { content: string; className?: string }) {
return <div className={className}>{renderMarkdown(content)}</div>;
}
export function NewsletterIssueView({ pubkey, identifier }: { pubkey: string; identifier: string }) {
const { t } = useTranslation();
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();

View file

@ -0,0 +1,63 @@
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { ADMIN_PUBKEYS } from '@/lib/admins';
import { APP_DATA_KIND } from '@/lib/types';
import type { NewsletterDraft } from '@/lib/newsletterContent';
const DRAFT_D = 'skatespots:newsletter:draft';
/**
* The latest weekly draft published by the generator (NIP-78, admin-authored).
* Public data — it contains no private keys or subscriber information.
*/
export function useNewsletterDraft(enabled = true) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
return useQuery({
queryKey: ['newsletter-draft', getQueryKeySuffix()],
enabled,
staleTime: 60_000,
queryFn: async (c): Promise<NewsletterDraft | null> => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
try {
const events = await querySkateContent(
[
{
kinds: [APP_DATA_KIND],
authors: ADMIN_PUBKEYS,
'#d': [DRAFT_D],
limit: 1,
},
],
{ signal },
);
const event = events.sort((a, b) => b.created_at - a.created_at)[0];
if (!event) return null;
const parsed = JSON.parse(event.content) as Partial<NewsletterDraft>;
if (
typeof parsed.number !== 'number' ||
typeof parsed.title !== 'string' ||
!parsed.title ||
typeof parsed.slug !== 'string' ||
typeof parsed.body !== 'string' ||
parsed.body.length < 20
) {
return null;
}
return {
number: parsed.number,
title: parsed.title,
slug: parsed.slug,
summary: typeof parsed.summary === 'string' ? parsed.summary : '',
image: typeof parsed.image === 'string' ? parsed.image : '',
body: parsed.body,
nostrText: typeof parsed.nostrText === 'string' ? parsed.nostrText : '',
coverTitle: typeof parsed.coverTitle === 'string' ? parsed.coverTitle : '',
createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : event.created_at,
};
} catch {
return null;
}
},
});
}

View file

@ -1331,7 +1331,13 @@ const batch1 = {
"errLogin": "Inicia sessió per passar a Pro.",
"errStart": "No s'ha pogut iniciar la compra.",
"errRecord": "No s'ha pogut registrar el teu passi."
}
},
newsletterDraftUi: {
weeklyDraftReady: "Esborrany setmanal a punt",
loadDraft: "Carrega l’esborrany",
hidePreview: "Amaga la vista prèvia",
untitled: "Sense títol",
},
};
export default batch1;

View file

@ -407,6 +407,12 @@ const drift = {
autoTranslateDesc: "Překládat příchozí zprávy do vašeho jazyka. Běží na tomto zařízení; nic se neodesílá.",
privacy: "Překlad běží na vašem zařízení. Vaše zpráva ho nikdy neopustí. Při prvním použití se stáhne jazykový model.",
},
newsletterDraftUi: {
weeklyDraftReady: "Týdenní koncept je připraven",
loadDraft: "Načíst koncept",
hidePreview: "Skrýt náhled",
untitled: "Bez názvu",
},
};
export default drift;

View file

@ -409,6 +409,12 @@ const drift = {
autoTranslateDesc: "Eingehende Nachrichten in deine Sprache übersetzen. Läuft auf diesem Gerät; nichts wird gesendet.",
privacy: "Die Übersetzung läuft auf deinem Gerät. Deine Nachricht verlässt es nie. Bei der ersten Nutzung wird ein Sprachmodell heruntergeladen.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Wöchentlicher Entwurf bereit',
loadDraft: 'Entwurf laden',
hidePreview: 'Vorschau ausblenden',
untitled: 'Ohne Titel',
},
};
export default drift;

View file

@ -411,6 +411,12 @@ const drift = {
autoTranslateDesc: "Μεταφράστε τα εισερχόμενα μηνύματα στη γλώσσα σας. Εκτελείται σε αυτή τη συσκευή· δεν αποστέλλεται τίποτα.",
privacy: "Η μετάφραση εκτελείται στη συσκευή σας. Το μήνυμά σας δεν την εγκαταλείπει ποτέ. Η πρώτη χρήση κατεβάζει ένα γλωσσικό μοντέλο.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Το εβδομαδιαίο πρόχειρο είναι έτοιμο',
loadDraft: 'Φόρτωση πρόχειρου',
hidePreview: 'Απόκρυψη προεπισκόπησης',
untitled: 'Χωρίς τίτλο',
},
};
export default drift;

View file

@ -1331,7 +1331,13 @@ const batch1 = {
errLogin: "Hasi saioa Pro-ra igotzeko.",
errStart: "Ezin izan da erosketa hasi.",
errRecord: "Ezin izan da zure pasa erregistratu."
}
},
newsletterDraftUi: {
weeklyDraftReady: 'Asteko zirriborroa prest',
loadDraft: 'Kargatu zirriborroa',
hidePreview: 'Ezkutatu aurrebista',
untitled: 'Izenbururik gabe',
},
};
export default batch1;

View file

@ -409,6 +409,12 @@ const drift = {
autoTranslateDesc: "Traduis les messages reçus dans ta langue. Fonctionne sur cet appareil ; rien n'est envoyé.",
privacy: "La traduction s'exécute sur ton appareil. Ton message ne le quitte jamais. La première utilisation télécharge un modèle de langue.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Brouillon hebdomadaire prêt',
loadDraft: 'Charger le brouillon',
hidePreview: 'Masquer l’aperçu',
untitled: 'Sans titre',
},
};
export default drift;

View file

@ -1331,7 +1331,13 @@ const batch1 = {
errLogin: "Inicia sesión para pasar a Pro.",
errStart: "Non se puido iniciar a compra.",
errRecord: "Non se puido rexistrar o teu pase."
}
},
newsletterDraftUi: {
weeklyDraftReady: 'Borrador semanal listo',
loadDraft: 'Cargar borrador',
hidePreview: 'Ocultar vista previa',
untitled: 'Sen título',
},
};
export default batch1;

View file

@ -1331,7 +1331,13 @@ const batch1 = {
"errLogin": "Pro में अपग्रेड करने के लिए लॉग इन करें।",
"errStart": "खरीदारी शुरू नहीं हो सकी।",
"errRecord": "आपका पास रिकॉर्ड नहीं हो सका।"
}
},
newsletterDraftUi: {
weeklyDraftReady: "साप्ताहिक ड्राफ़्ट तैयार है",
loadDraft: "ड्राफ़्ट लोड करें",
hidePreview: "पूर्वावलोकन छिपाएँ",
untitled: "बिना शीर्षक",
},
};
export default batch1;

View file

@ -403,6 +403,12 @@ const driftStrings = {
autoTranslateDesc: 'Terjemahkan pesan masuk ke bahasa Anda. Berjalan di perangkat ini; tidak ada yang dikirim.',
privacy: 'Terjemahan berjalan di perangkat Anda. Pesan Anda tidak pernah meninggalkannya. Penggunaan pertama mengunduh model bahasa.',
},
newsletterDraftUi: {
weeklyDraftReady: 'Draf mingguan siap',
loadDraft: 'Muat draf',
hidePreview: 'Sembunyikan pratinjau',
untitled: 'Tanpa judul',
},
};
export default driftStrings;

View file

@ -409,6 +409,12 @@ const drift = {
autoTranslateDesc: "Traduci i messaggi in arrivo nella tua lingua. Funziona su questo dispositivo; nulla viene inviato.",
privacy: "La traduzione viene eseguita sul tuo dispositivo. Il tuo messaggio non lo lascia mai. Al primo utilizzo viene scaricato un modello linguistico.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Bozza settimanale pronta',
loadDraft: 'Carica bozza',
hidePreview: 'Nascondi anteprima',
untitled: 'Senza titolo',
},
};
export default drift;

View file

@ -419,6 +419,12 @@ const drift: Dict = {
autoTranslateDesc: '受信メッセージをあなたの言語に翻訳します。このデバイス上で動作し、何も送信されません。',
privacy: '翻訳はお使いのデバイス上で実行されます。メッセージが外部に送られることはありません。初回利用時に言語モデルをダウンロードします。',
},
newsletterDraftUi: {
weeklyDraftReady: '週間下書きの準備ができました',
loadDraft: '下書きを読み込む',
hidePreview: 'プレビューを隠す',
untitled: '無題',
},
};
export default drift;

View file

@ -419,6 +419,12 @@ const drift: Dict = {
autoTranslateDesc: '받은 메시지를 내 언어로 번역합니다. 이 기기에서 실행되며 아무것도 전송되지 않습니다.',
privacy: '번역은 사용자의 기기에서 실행됩니다. 메시지는 기기를 떠나지 않습니다. 처음 사용할 때 언어 모델을 다운로드합니다.',
},
newsletterDraftUi: {
weeklyDraftReady: '주간 초안 준비 완료',
loadDraft: '초안 불러오기',
hidePreview: '미리보기 숨기기',
untitled: '제목 없음',
},
};
export default drift;

View file

@ -6,6 +6,7 @@ import notify from './notify';
import misc from './misc';
import cordnUi from './cordnUi';
import communityUi from './communityUi';
import newsletterDraftUi from './newsletterDraftUi';
/**
* Namespaces added by the hardcoded-string sweep (2026-10-07), spread into
@ -21,6 +22,7 @@ export const legacyEn = {
legacyMiscUi: misc.en,
cordnUi: cordnUi.en,
communityUi: communityUi.en,
newsletterDraftUi: newsletterDraftUi.en,
};
export const legacyEs = {
@ -32,4 +34,5 @@ export const legacyEs = {
legacyMiscUi: misc.es,
cordnUi: cordnUi.es,
communityUi: communityUi.es,
newsletterDraftUi: newsletterDraftUi.es,
};

View file

@ -0,0 +1,16 @@
const newsletterDraftUi = {
en: {
weeklyDraftReady: 'Weekly draft ready',
loadDraft: 'Load draft',
hidePreview: 'Hide preview',
untitled: 'Untitled',
},
es: {
weeklyDraftReady: 'Borrador semanal listo',
loadDraft: 'Cargar borrador',
hidePreview: 'Ocultar vista previa',
untitled: 'Sin título',
},
};
export default newsletterDraftUi;

View file

@ -407,6 +407,12 @@ const drift = {
autoTranslateDesc: "Vertaal inkomende berichten naar jouw taal. Draait op dit apparaat; er wordt niets verzonden.",
privacy: "De vertaling draait op je apparaat. Je bericht verlaat het nooit. Bij het eerste gebruik wordt een taalmodel gedownload.",
},
newsletterDraftUi: {
weeklyDraftReady: "Wekelijks concept klaar",
loadDraft: "Concept laden",
hidePreview: "Voorbeeld verbergen",
untitled: "Zonder titel",
},
};
export default drift;

View file

@ -407,6 +407,12 @@ const drift = {
autoTranslateDesc: "Tłumacz przychodzące wiadomości na swój język. Działa na tym urządzeniu; nic nie jest wysyłane.",
privacy: "Tłumaczenie działa na Twoim urządzeniu. Twoja wiadomość nigdy go nie opuszcza. Przy pierwszym użyciu pobierany jest model językowy.",
},
newsletterDraftUi: {
weeklyDraftReady: "Tygodniowy szkic gotowy",
loadDraft: "Wczytaj szkic",
hidePreview: "Ukryj podgląd",
untitled: "Bez tytułu",
},
};
export default drift;

View file

@ -409,6 +409,12 @@ const drift = {
autoTranslateDesc: "Traduza as mensagens recebidas para o seu idioma. Roda neste dispositivo; nada é enviado.",
privacy: "A tradução roda no seu dispositivo. Sua mensagem nunca sai dele. No primeiro uso, um modelo de idioma é baixado.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Rascunho semanal pronto',
loadDraft: 'Carregar rascunho',
hidePreview: 'Ocultar pré-visualização',
untitled: 'Sem título',
},
};
export default drift;

View file

@ -407,6 +407,12 @@ const drift = {
autoTranslateDesc: "Tradu mesajele primite în limba ta. Rulează pe acest dispozitiv; nu se trimite nimic.",
privacy: "Traducerea rulează pe dispozitivul tău. Mesajul tău nu îl părăsește niciodată. La prima utilizare se descarcă un model lingvistic.",
},
newsletterDraftUi: {
weeklyDraftReady: "Ciornă săptămânală pregătită",
loadDraft: "Încarcă ciorna",
hidePreview: "Ascunde previzualizarea",
untitled: "Fără titlu",
},
};
export default drift;

View file

@ -411,6 +411,12 @@ const drift = {
autoTranslateDesc: "Переводить входящие сообщения на ваш язык. Работает на этом устройстве; ничего не отправляется.",
privacy: "Перевод выполняется на вашем устройстве. Ваше сообщение его никогда не покидает. При первом использовании загружается языковая модель.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Еженедельный черновик готов',
loadDraft: 'Загрузить черновик',
hidePreview: 'Скрыть предпросмотр',
untitled: 'Без названия',
},
};
export default drift;

View file

@ -411,6 +411,12 @@ const drift = {
autoTranslateDesc: "Översätt inkommande meddelanden till ditt språk. Körs på den här enheten; inget skickas.",
privacy: "Översättningen körs på din enhet. Ditt meddelande lämnar den aldrig. Vid första användningen laddas en språkmodell ned.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Veckoutkastet är klart',
loadDraft: 'Ladda utkast',
hidePreview: 'Dölj förhandsvisning',
untitled: 'Utan titel',
},
};
export default drift;

View file

@ -403,6 +403,12 @@ const driftStrings = {
autoTranslateDesc: 'แปลข้อความที่เข้ามาเป็นภาษาของคุณ ทำงานบนอุปกรณ์นี้ ไม่มีการส่งข้อมูลใด ๆ',
privacy: 'การแปลทำงานบนอุปกรณ์ของคุณ ข้อความของคุณจะไม่ออกจากเครื่อง การใช้งานครั้งแรกจะดาวน์โหลดโมเดลภาษา',
},
newsletterDraftUi: {
weeklyDraftReady: 'ฉบับร่างรายสัปดาห์พร้อมแล้ว',
loadDraft: 'โหลดฉบับร่าง',
hidePreview: 'ซ่อนตัวอย่าง',
untitled: 'ไม่มีชื่อ',
},
};
export default driftStrings;

View file

@ -403,6 +403,12 @@ const driftStrings = {
autoTranslateDesc: 'Gelen mesajları kendi dilinize çevirin. Bu cihazda çalışır; hiçbir şey gönderilmez.',
privacy: 'Çeviri cihazınızda çalışır. Mesajınız cihazınızdan asla ayrılmaz. İlk kullanımda bir dil modeli indirilir.',
},
newsletterDraftUi: {
weeklyDraftReady: 'Haftalık taslak hazır',
loadDraft: 'Taslağı yükle',
hidePreview: 'Önizlemeyi gizle',
untitled: 'Başlıksız',
},
};
export default driftStrings;

View file

@ -411,6 +411,12 @@ const drift = {
autoTranslateDesc: "Перекладати вхідні повідомлення вашою мовою. Працює на цьому пристрої; нічого не надсилається.",
privacy: "Переклад виконується на вашому пристрої. Ваше повідомлення ніколи його не залишає. Під час першого використання завантажується мовна модель.",
},
newsletterDraftUi: {
weeklyDraftReady: 'Тижнева чернетка готова',
loadDraft: 'Завантажити чернетку',
hidePreview: 'Приховати попередній перегляд',
untitled: 'Без назви',
},
};
export default drift;

View file

@ -403,6 +403,12 @@ const driftStrings = {
autoTranslateDesc: 'Dịch tin nhắn đến sang ngôn ngữ của bạn. Chạy trên thiết bị này; không gửi dữ liệu đi đâu.',
privacy: 'Tính năng dịch chạy trên thiết bị của bạn. Tin nhắn của bạn không bao giờ rời khỏi đó. Lần sử dụng đầu tiên sẽ tải xuống một mô hình ngôn ngữ.',
},
newsletterDraftUi: {
weeklyDraftReady: 'Bản nháp hàng tuần đã sẵn sàng',
loadDraft: 'Tải bản nháp',
hidePreview: 'Ẩn bản xem trước',
untitled: 'Không có tiêu đề',
},
};
export default driftStrings;

View file

@ -419,6 +419,12 @@ const drift: Dict = {
autoTranslateDesc: '将收到的消息翻译成你的语言。在此设备上运行,不会发送任何内容。',
privacy: '翻译在此设备上运行。你的消息永远不会离开设备。首次使用时会下载语言模型。',
},
newsletterDraftUi: {
weeklyDraftReady: '每周草稿已就绪',
loadDraft: '加载草稿',
hidePreview: '隐藏预览',
untitled: '未命名',
},
};
export default drift;

View file

@ -419,6 +419,12 @@ const drift: Dict = {
autoTranslateDesc: '將收到的訊息翻譯成你的語言。在此裝置上執行,不會傳送任何內容。',
privacy: '翻譯在此裝置上執行。你的訊息永遠不會離開裝置。首次使用時會下載語言模型。',
},
newsletterDraftUi: {
weeklyDraftReady: '每週草稿已就緒',
loadDraft: '載入草稿',
hidePreview: '隱藏預覽',
untitled: '未命名',
},
};
export default drift;

View file

@ -0,0 +1,204 @@
/**
* Weekly newsletter generator content.
*
* This module is browser-safe and intentionally has no network access. The
* weekly Node script supplies the dynamic bits (issue number, cover URL, live
* headlines); this file owns the editorial voice, the rotating joke bank and the
* release-note fallback highlights.
*
* Tone: South Park roast — hyperbolic, sweary, opinionated, never presented as
* factual reporting. The issue footer says so.
*/
export interface WeeklyHighlight {
tag: string;
title: string;
body: string;
}
export interface WeeklyJoke {
id: string;
title: string;
body: string;
}
export interface WeeklyHeadline {
title: string;
url: string;
source: string;
}
export interface NewsletterDraft {
number: number;
title: string;
slug: string;
summary: string;
image: string;
body: string;
nostrText: string;
coverTitle: string;
createdAt: number;
}
/** Curation fallback for the current cycle; the script prefers fresh release notes. */
export const WEEKLY_HIGHLIGHTS: WeeklyHighlight[] = [
{
tag: 'Private chat',
title: 'Unread dots, remembered joins, and a doorman with a key package',
body:
'The Private chat tab now tells you when someone said something while you were reading Events or Chat. A pending join survives a reload, founders get a request dot, and a new phone can be linked by QR instead of another approval round trip. We also added member removal and copy/reply.',
},
{
tag: 'Sign-in',
title: 'The menu stopped eating the login dialog',
body:
'Signing in from the burger menu used to open a dialog behind the sheet, which is a fancy way of saying “nothing happened.” Auth now lives above every menu, closes the menu first, and survives signup from any card. The desktop welcome prompt no longer races you to the login button.',
},
{
tag: 'Community',
title: 'A public room with a bot doorman',
body:
'The new SkateSpots Community room is end-to-end encrypted, auto-approved by a small bot, and linked from the menu/About. Short links: skate-spots.org/community for humans, skate-spots.org/c for Cordn/Staircase people.',
},
{
tag: 'Security',
title: 'Zero critical npm advisories',
body:
'We pinned the vulnerable protobufjs path used by the on-device translation runtime and updated sharp. The only remaining high is xlsx, which this app only writes. Translation still never sends your messages anywhere.',
},
];
/**
* Rotating roast bank. Each issue picks three by issue number; no need to
* re-read the same joke every week. These are satire/opinion, not reporting.
*/
export const WEEKLY_JOKES: WeeklyJoke[] = [
{
id: 'funding',
title: 'The funding-to-adoption pipeline remains undefeated',
body:
'Somewhere, a deck with 47 slides just raised another twenty million to build a marginally different timeline for the same nine users. Jack, buddy, we love the protocol, but at some point “decentralized” has to mean more than a seed round and a Discord with twelve people. The product is not the network. The product is whether your friends show up.',
},
{
id: 'fiat',
title: 'Fiat’s greatest hits, now with a subscription model',
body:
'The money printer goes brrr, the grocery bill goes “that’s not the price we agreed on,” and your salary auto-renews at a higher price every year. Your savings account is a subscription to a currency that gets worse. Bitcoin fixes this, allegedly, right after another eighteen months of sideways. Until then: buy the dip, sell the rips, and never check the chart while skating.',
},
{
id: 'vc',
title: 'VCs and influencers: two faces, one bag',
body:
'Publicly: “we’re here for the users.” Privately: “we’re here for the exit.” The influencer says “not financial advice” while holding a bag the size of a mattress; the VC says “long-term” with a vesting cliff shorter than a TikTok. The only thing decentralized is the accountability.',
},
{
id: 'ngos',
title: 'Human Rights™ discovers Bitcoin',
body:
'An NGO finds Bitcoin and announces it is “the perfect tool for freedom.” Then it hires a PR firm, writes a narrative nobody in the room believes, and launches a centralized donation portal with a KYC form. The revolution will be compliance-reviewed. Please donate in fiat so we can convert it at the worst possible moment.',
},
{
id: 'zuck',
title: 'Mark Zuckerberg has entered the skate park',
body:
'Mark saw skateboarding and thought: “what this needs is a headset.” Instagram already turned every kickflip into an engagement farm; now the metaverse wants a piece of the culture. The only thing more captured than a 5-0 grind is a skateboarder’s data. Wear a helmet, and maybe a privacy policy.',
},
{
id: 'pegged',
title: '“Bitcoin fixes this” meets a pegged fractional reserve',
body:
'Bitcoiners: “Bitcoin fixes this.” Also bitcoiners: “I hold a pegged IOU from a fractional reserve that promises to hold bitcoin.” Congratulations, you reinvented banking with a laser-eyes sticker. “Trust me bro” is not an audit, and a PDF is not a reserve. Read the contract, then read it again.',
},
{
id: 'nostrclone',
title: 'Another timeline, another twenty million',
body:
'Four years, twenty-something million dollars, and the flagship feature is a timeline. A timeline. The same nine users, now with a quote-post button. Meanwhile we spent the week making the map draw a line and the private chat not eat itself. Someone has to do the boring part.',
},
{
id: 'agents',
title: 'Every app now has an AI agent',
body:
'Your agent can summarize the chat you weren’t in, draft the reply you don’t mean, and hallucinate a brand partnership — all before lunch. We taught a parrot to sell you a subscription. Progress is a word we’re using loosely.',
},
];
export function pickJokes(issueNumber: number, count = 3): WeeklyJoke[] {
const start = (Math.abs(issueNumber) + 1) % WEEKLY_JOKES.length;
return Array.from({ length: Math.min(count, WEEKLY_JOKES.length) }, (_, i) =>
WEEKLY_JOKES[(start + i) % WEEKLY_JOKES.length],
);
}
export function buildWeeklyDraft(params: {
number: number;
dateIso: string;
coverImage: string;
coverTitle: string;
highlights: WeeklyHighlight[];
headlines: WeeklyHeadline[];
jokes?: WeeklyJoke[];
}): NewsletterDraft {
const { number, dateIso, coverImage, coverTitle } = params;
const highlights = params.highlights.slice(0, 4);
const jokes = params.jokes ?? pickJokes(number, 3);
const headlines = params.headlines.slice(0, 3);
const summary =
'The private chat grows up, the menu stops eating sign-ins, and the funding class discovers another timeline.';
const sections: string[] = [];
sections.push('## What shipped');
for (const highlight of highlights) {
sections.push(`### ${highlight.title}`);
sections.push(`**${highlight.tag}.** ${highlight.body}`);
}
sections.push('## The week in nonsense');
sections.push(
'_Satire/opinion. No financial advice, no factual reporting, no one’s lawyer approved this section._',
);
for (const joke of jokes) {
sections.push(`### ${joke.title}`);
sections.push(joke.body);
}
if (headlines.length > 0) {
sections.push('## Wires from the timeline');
for (const headline of headlines) {
sections.push(`- [${headline.title}](${headline.url}) — _${headline.source}_`);
}
}
sections.push('## Go outside');
sections.push(
'The map is still the point: find a spot, add a photo, join a crew, or ask a question in the community room. Everything private stays encrypted; everything public is yours to fork.',
);
sections.push(
'_SkateSpots is AGPL-3.0-only, self-hosted where it matters, and funded by people who skate — not by a token, a VC, or a guy with a laser-eyes profile picture._',
);
const body = sections.join('\n\n');
const title = `SkateSpots Drop #${String(number).padStart(3, '0')}`;
const slug = `drop-${String(number).padStart(3, '0')}-${dateIso.replace(/-/g, '')}`;
const nostrText = [
`SkateSpots Drop #${String(number).padStart(3, '0')}`,
'',
summary,
'',
'https://skate-spots.org/newsletter',
'',
'#skateboarding #nostr #skatespots #newsletter #skatespotsnews',
].join('\n');
return {
number,
title,
slug,
summary,
image: coverImage,
body,
nostrText,
coverTitle,
createdAt: Math.floor(new Date(`${dateIso}T12:00:00Z`).getTime() / 1000),
};
}

View file

@ -32,6 +32,7 @@ import { NewsletterSubscribeButton } from '@/components/NewsletterSubscribeButto
import { NewsletterComposer } from '@/components/NewsletterComposer';
import { NewsletterBackfillPanel } from '@/components/NewsletterBackfillPanel';
import { useNewsletterIssues } from '@/hooks/useNewsletterIssues';
import { useNewsletterDraft } from '@/hooks/useNewsletterDraft';
import { useNewsletterSubscriberCount } from '@/hooks/useNewsletterSubscriberCount';
import { useNewsletterRead } from '@/hooks/useNewsletterRead';
import { NEWSLETTER_KIND, type SkateNewsletterIssue } from '@/lib/types';
@ -628,6 +629,15 @@ function LiveIssuesSection() {
export default function NewsletterPage() {
const { t } = useTranslation();
const { user } = useCurrentUser();
const { data: weeklyDraft } = useNewsletterDraft(isAdminPubkey(user?.pubkey));
const { data: liveIssues = [] } = useNewsletterIssues();
const liveIssueNumbers = new Set(
liveIssues
.map((issue) => Number(issue.title.match(/#0*(\d+)/)?.[1]))
.filter((value) => Number.isFinite(value)),
);
const staticIssues = NEWSLETTER_ISSUES.filter((issue) => !liveIssueNumbers.has(issue.number));
useSeoMeta({
title: t('seoUi.newsletterTitle'),
description: t('seoUi.newsletterDesc'),
@ -675,6 +685,15 @@ export default function NewsletterPage() {
<NewsletterComposer />
</div>
<SubscriberCount />
{isAdminPubkey(user?.pubkey) && weeklyDraft && (
<div className="mt-2 flex w-full max-w-sm flex-wrap items-center justify-between gap-2 rounded-xl border border-[#ccff00]/30 bg-[#ccff00]/5 px-3 py-2 text-left">
<div className="min-w-0">
<p className="text-xs font-bold text-[#ccff00]">{t('newsletterDraftUi.weeklyDraftReady')}</p>
<p className="truncate text-[11px] text-gray-500">{weeklyDraft.title}</p>
</div>
<NewsletterComposer triggerLabel={t('newsletterDraftUi.loadDraft')} autoLoadDraft />
</div>
)}
<p className="max-w-xs text-[11px] leading-snug text-gray-600">
{t('walletUi.newsletter.freeNote')}
</p>
@ -688,7 +707,7 @@ export default function NewsletterPage() {
{/* ── All issues ── */}
<div className="space-y-20">
{NEWSLETTER_ISSUES.map((issue) => (
{staticIssues.map((issue) => (
<IssueArticle key={issue.number} issue={issue} />
))}
</div>