Merge branch 'main' of gitlab.com:soapbox-pub/ditto-mew

This commit is contained in:
Alex Gleason
2026-02-25 13:47:14 -06:00
5 changed files with 123 additions and 3 deletions
+75
View File
@@ -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 (
<Dialog>
<DialogTrigger asChild>
<Button variant="ghost" size="icon" className="size-8 rounded-full text-muted-foreground hover:text-foreground">
<Info className="size-4" />
</Button>
</DialogTrigger>
<DialogContent className="max-w-xs p-6">
<div className="flex flex-col items-center text-center gap-4">
{icon && (
<div className="text-primary [&>svg]:size-10">
{icon}
</div>
)}
<DialogTitle className="text-lg">{label}</DialogTitle>
{blurb && (
<DialogDescription className="text-sm leading-relaxed">
{blurb}
</DialogDescription>
)}
{sites && sites.length > 0 && (
<div className="w-full space-y-1.5 pt-1">
{sites.map((site) => {
const hostname = new URL(site.url).hostname;
const name = site.name ?? hostname.split('.')[0].replace(/^./, (c) => c.toUpperCase());
return (
<a
key={site.url}
href={site.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors"
>
<ExternalFavicon url={site.url} size={16} />
{name}
<ExternalLink className="size-3.5 opacity-70" />
</a>
);
})}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
+28
View File
@@ -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,
+13 -2
View File
@@ -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
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
{icon}
<h1 className="text-xl font-bold">{title}</h1>
</div>
{resolvedDef && <KindInfoButton kindDef={resolvedDef} icon={icon} />}
</div>
}
/>
+6 -1
View File
@@ -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() {
<Link to="/" className="p-2 -ml-2 rounded-full hover:bg-secondary transition-colors sidebar:hidden">
<ArrowLeft className="size-5" />
</Link>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 flex-1 min-w-0">
<Radio className="size-5" />
<h1 className="text-xl font-bold">Streams</h1>
</div>
<KindInfoButton kindDef={streamsDef} icon={<Radio className="size-5" />} />
</div>
{/* Feed */}
+1
View File
@@ -15,6 +15,7 @@ export function TreasuresPage() {
kind={kinds}
title="Treasures"
icon={<ChestIcon className="size-5" />}
kindDef={treasuresDef}
emptyMessage={
kinds.length === 0
? 'All treasure types are disabled. Enable geocaches or found logs in Settings > Feed.'