+
} />
+
+
+ setRecent(prev => addRecent(prev, link))} />
+ {
+ clearRecent()
+ setRecent([])
+ }}
+ />
+
+
+
+ )
+}
+
+export default R3drPage
diff --git a/src/apps/r3dr/recent.ts b/src/apps/r3dr/recent.ts
new file mode 100644
index 0000000..ab59693
--- /dev/null
+++ b/src/apps/r3dr/recent.ts
@@ -0,0 +1,58 @@
+// Recent links live only in this browser: the API has no list endpoint, and
+// a shortener doesn't need accounts to be useful. localStorage can throw
+// (private windows, blocked storage) — every touch is wrapped, and the page
+// works identically with none.
+export interface RecentLink {
+ slug: string
+ longUrl: string
+ expiresAt: number
+}
+
+const KEY = 'r3dr.recent'
+const MAX = 5
+
+// Slugs are exactly 3, 6, or 11 base64url chars (the encoder's widths). A
+// hand-edited store must not render arbitrary text — or never-resolvable
+// lengths — into links.
+const SLUG_SHAPE = /^(?:[A-Za-z0-9_-]{3}|[A-Za-z0-9_-]{6}|[A-Za-z0-9_-]{11})$/
+
+export function loadRecent(now: number): RecentLink[] {
+ try {
+ const raw = localStorage.getItem(KEY)
+ if (!raw) return []
+ const parsed: unknown = JSON.parse(raw)
+ if (!Array.isArray(parsed)) return []
+ return parsed
+ .filter(
+ (entry): entry is RecentLink =>
+ entry !== null &&
+ typeof entry === 'object' &&
+ typeof (entry as RecentLink).slug === 'string' &&
+ typeof (entry as RecentLink).longUrl === 'string' &&
+ typeof (entry as RecentLink).expiresAt === 'number'
+ )
+ .filter(entry => SLUG_SHAPE.test(entry.slug) && entry.expiresAt > now)
+ .slice(0, MAX)
+ } catch {
+ return []
+ }
+}
+
+/** Newest first, deduped by slug, capped. Returns the list to render. */
+export function addRecent(links: RecentLink[], link: RecentLink): RecentLink[] {
+ const next = [link, ...links.filter(l => l.slug !== link.slug)].slice(0, MAX)
+ try {
+ localStorage.setItem(KEY, JSON.stringify(next))
+ } catch {
+ // storage unavailable; the in-memory list still renders
+ }
+ return next
+}
+
+export function clearRecent(): void {
+ try {
+ localStorage.removeItem(KEY)
+ } catch {
+ // nothing to clear
+ }
+}
diff --git a/src/apps/r3dr/urlInput.ts b/src/apps/r3dr/urlInput.ts
new file mode 100644
index 0000000..484adf5
--- /dev/null
+++ b/src/apps/r3dr/urlInput.ts
@@ -0,0 +1,24 @@
+// Bare domains are the common paste; give them the scheme the API requires.
+// Schemes are case-insensitive (the API's pattern is not), so an uppercased
+// http(s) is lowercased rather than bounced with a confusing message. Other
+// schemes pass through untouched and fail validation.
+export function normalizeUrl(input: string): string {
+ const trimmed = input.trim()
+ if (/^https?:\/\//i.test(trimmed)) {
+ return trimmed.replace(/^[^:]+/, scheme => scheme.toLowerCase())
+ }
+ if (trimmed === '' || /^[a-z][a-z0-9+.-]*:/i.test(trimmed)) return trimmed
+ return `https://${trimmed}`
+}
+
+// The API's traits (@length 11–1000, @pattern ^https?://), mirrored for
+// instant feedback. Lengths count code points, like the server does. The
+// server still enforces them.
+export function validateUrl(url: string): string | null {
+ if (url === '') return 'Paste a link first.'
+ if (!/^https?:\/\//.test(url)) return 'Links must start with http:// or https://.'
+ const length = [...url].length
+ if (length < 11) return 'That URL looks too short.'
+ if (length > 1000) return 'URLs top out at 1000 characters.'
+ return null
+}
diff --git a/src/shared/components/Navigation.tsx b/src/shared/components/Navigation.tsx
index 8c13092..a39089d 100644
--- a/src/shared/components/Navigation.tsx
+++ b/src/shared/components/Navigation.tsx
@@ -36,6 +36,7 @@ const MENU: MenuGroup[] = [
{ label: 'Posterize', to: '/posterize' },
{ label: 'Metrics', to: '/metrics' },
{ label: 'Wordchains', to: '/wordchains' },
+ { label: 'r3dr', to: '/r3dr', description: 'URL shortener' },
],
},
{
@@ -52,7 +53,6 @@ const MENU: MenuGroup[] = [
name: 'elsewhere',
label: 'Elsewhere',
links: [
- { label: 'r3dr', to: 'https://r3dr.net', external: true, description: 'URL shortener' },
{ label: 'Snowbonk', to: 'https://snowbonk.com', external: true, description: 'N-body simulation viewer' },
{ label: '1d4', to: 'https://1d4.net', external: true, description: 'Chess game indexer' },
{ label: 'HoverCrap', to: 'https://hovercrap.com', external: true, description: 'ASCII hovercraft' },
diff --git a/src/shared/components/__tests__/Navigation.test.tsx b/src/shared/components/__tests__/Navigation.test.tsx
index f7d0b11..a3edddf 100644
--- a/src/shared/components/__tests__/Navigation.test.tsx
+++ b/src/shared/components/__tests__/Navigation.test.tsx
@@ -36,7 +36,6 @@ describe('Navigation', () => {
if (!groupEl) throw new Error('Elsewhere nav group not found')
const group = within(groupEl)
const expected: Array<[RegExp, string, string]> = [
- [/^r3dr\s?\(external site\)/, 'https://r3dr.net', 'URL shortener'],
[/^Snowbonk\s?\(external site\)/, 'https://snowbonk.com', 'N-body simulation viewer'],
[/^1d4\s?\(external site\)/, 'https://1d4.net', 'Chess game indexer'],
[/^HoverCrap\s?\(external site\)/, 'https://hovercrap.com', 'ASCII hovercraft'],
@@ -55,6 +54,18 @@ describe('Navigation', () => {
}
})
+ // r3dr moved from Elsewhere to Projects when the page moved into this
+ // site (#1359 chunk 2): internal now, description intact.
+ it('links r3dr as an internal Projects page', () => {
+ renderWithRouter(