From ea5ce58c7fce7db163461862a30649885115dda8 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Tue, 24 Feb 2026 23:22:41 -0600 Subject: [PATCH] Add info modal with blurb and external site links to all extra kind pages --- src/components/KindInfoButton.tsx | 75 +++++++++++++++++++++++++++++++ src/lib/extraKinds.ts | 28 ++++++++++++ src/pages/KindFeedPage.tsx | 15 ++++++- src/pages/StreamsFeedPage.tsx | 7 ++- src/pages/TreasuresPage.tsx | 1 + 5 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/components/KindInfoButton.tsx diff --git a/src/components/KindInfoButton.tsx b/src/components/KindInfoButton.tsx new file mode 100644 index 00000000..fc204ff9 --- /dev/null +++ b/src/components/KindInfoButton.tsx @@ -0,0 +1,75 @@ +import { ExternalLink, Info } from 'lucide-react'; + +import type { ExtraKindDef } from '@/lib/extraKinds'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; +import { ExternalFavicon } from '@/components/ExternalFavicon'; + +interface KindInfoButtonProps { + kindDef: ExtraKindDef; + icon?: React.ReactNode; +} + +/** Info button that opens a modal with a blurb and external site links for an extra kind. */ +export function KindInfoButton({ kindDef, icon }: KindInfoButtonProps) { + const { label, blurb, sites } = kindDef; + + if (!sites?.length && !blurb) return null; + + return ( + + + + + + +
+ {icon && ( +
+ {icon} +
+ )} + + {label} + + {blurb && ( + + {blurb} + + )} + + {sites && sites.length > 0 && ( +
+ {sites.map((site) => { + const hostname = new URL(site.url).hostname; + const name = site.name ?? hostname.split('.')[0].replace(/^./, (c) => c.toUpperCase()); + + return ( + + + {name} + + + ); + })} +
+ )} +
+
+
+ ); +} diff --git a/src/lib/extraKinds.ts b/src/lib/extraKinds.ts index f2e99031..d477adfc 100644 --- a/src/lib/extraKinds.ts +++ b/src/lib/extraKinds.ts @@ -15,6 +15,14 @@ export interface SubKindDef { addressable: boolean; } +/** An external site where users can create or participate in a specific kind. */ +export interface ExtraKindSite { + /** Full URL to the site. */ + url: string; + /** Display name override. Defaults to the first segment of the hostname, capitalized. */ + name?: string; +} + /** Section labels for grouping extra kinds in settings UI. */ export type ExtraKindSection = 'feed' | 'media' | 'social' | 'whimsy'; @@ -50,6 +58,10 @@ export interface ExtraKindDef { section: ExtraKindSection; /** If true, this entry only has a feed toggle (no sidebar toggle). */ feedOnly?: boolean; + /** Longer, whimsical blurb shown in the info modal. */ + blurb?: string; + /** External sites where users can create or participate in this kind of content. */ + sites?: ExtraKindSite[]; } /** All supported extra content kinds, ordered by section (feed → media → social → whimsy). */ @@ -91,6 +103,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'articles', addressable: true, section: 'feed', + blurb: 'Blog posts, essays, and guides. Write and publish from a dedicated editor.', + sites: [{ url: 'https://inkwell.shakespeare.wtf' }], }, // Media { @@ -102,6 +116,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'vines', addressable: true, section: 'media', + blurb: 'Short video clips. Record and share from a dedicated app.', + sites: [{ url: 'https://divine.video' }], }, { kind: 30311, @@ -112,6 +128,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'streams', addressable: true, section: 'media', + blurb: 'Watch and broadcast live video. Start a stream from a streaming app.', + sites: [{ url: 'https://zap.stream', name: 'zap.stream' }], }, // Social { @@ -123,6 +141,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'polls', addressable: false, section: 'social', + blurb: 'Ask a question, let people vote. Create polls from a polling app.', + sites: [{ url: 'https://pollerama.fun' }], }, { kind: 39089, @@ -133,6 +153,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'packs', addressable: true, section: 'social', + blurb: 'Curated lists of people to follow. Browse or create your own.', + sites: [{ url: 'https://following.space', name: 'following.space' }, { url: 'https://following.party', name: 'following.party' }], }, // Whimsy { @@ -144,6 +166,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'colors', addressable: false, section: 'whimsy', + blurb: 'Share your mood as a color palette. Pick colors that match the moment.', + sites: [{ url: 'https://espy.you' }], }, { kind: 37381, @@ -154,6 +178,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'decks', addressable: true, section: 'whimsy', + blurb: 'Magic: The Gathering deck lists. Build and share decks with other players.', + sites: [{ url: 'https://surveil.cards' }], }, { kind: 37516, @@ -163,6 +189,8 @@ export const EXTRA_KINDS: ExtraKindDef[] = [ route: 'treasures', addressable: true, section: 'whimsy', + blurb: 'Real-world geocaching. Hide treasures outside, find others, and log your discoveries.', + sites: [{ url: 'https://treasures.to' }], subKinds: [ { kind: 37516, diff --git a/src/pages/KindFeedPage.tsx b/src/pages/KindFeedPage.tsx index eabde8ce..ad716417 100644 --- a/src/pages/KindFeedPage.tsx +++ b/src/pages/KindFeedPage.tsx @@ -1,19 +1,29 @@ +import { useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; import { ArrowLeft } from 'lucide-react'; import { Link } from 'react-router-dom'; import { Feed } from '@/components/Feed'; +import { KindInfoButton } from '@/components/KindInfoButton'; import { useLayoutOptions } from '@/contexts/LayoutContext'; +import { EXTRA_KINDS, type ExtraKindDef } from '@/lib/extraKinds'; interface KindFeedPageProps { kind: number | number[]; title: string; icon?: React.ReactNode; emptyMessage?: string; + /** Override the auto-detected ExtraKindDef (useful for pages with sub-kinds like Treasures). */ + kindDef?: ExtraKindDef; } -export function KindFeedPage({ kind, title, icon, emptyMessage }: KindFeedPageProps) { +export function KindFeedPage({ kind, title, icon, emptyMessage, kindDef }: KindFeedPageProps) { const primaryKind = Array.isArray(kind) ? kind[0] : kind; + const resolvedDef = useMemo( + () => kindDef ?? EXTRA_KINDS.find((def) => def.kind === primaryKind), + [kindDef, primaryKind], + ); + useSeoMeta({ title: `${title} | Ditto`, description: `${title} on Nostr`, @@ -33,10 +43,11 @@ export function KindFeedPage({ kind, title, icon, emptyMessage }: KindFeedPagePr -
+
{icon}

{title}

+ {resolvedDef && }
} /> diff --git a/src/pages/StreamsFeedPage.tsx b/src/pages/StreamsFeedPage.tsx index 3e874c52..de821c12 100644 --- a/src/pages/StreamsFeedPage.tsx +++ b/src/pages/StreamsFeedPage.tsx @@ -9,15 +9,19 @@ import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { Card, CardContent } from '@/components/ui/card'; +import { KindInfoButton } from '@/components/KindInfoButton'; import { useAuthor } from '@/hooks/useAuthor'; import { useStreamKind } from '@/hooks/useStreamKind'; import { useLayoutOptions } from '@/contexts/LayoutContext'; import { getDisplayName } from '@/lib/getDisplayName'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { useOpenPost } from '@/hooks/useOpenPost'; +import { EXTRA_KINDS } from '@/lib/extraKinds'; import { timeAgo } from '@/lib/timeAgo'; import { cn } from '@/lib/utils'; +const streamsDef = EXTRA_KINDS.find((def) => def.route === 'streams')!; + /** Extract the first value of a tag by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; @@ -66,10 +70,11 @@ export function StreamsFeedPage() { -
+

Streams

+ } />
{/* Feed */} diff --git a/src/pages/TreasuresPage.tsx b/src/pages/TreasuresPage.tsx index d8a1c417..c06d0789 100644 --- a/src/pages/TreasuresPage.tsx +++ b/src/pages/TreasuresPage.tsx @@ -15,6 +15,7 @@ export function TreasuresPage() { kind={kinds} title="Treasures" icon={} + kindDef={treasuresDef} emptyMessage={ kinds.length === 0 ? 'All treasure types are disabled. Enable geocaches or found logs in Settings > Feed.'