Fix navigation, add highlights tab to profile, enable markdown highlighting, extract HighlightCard component

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2025-10-06 18:11:03 +00:00
parent 7f85dd99e6
commit f173a84bef
5 changed files with 232 additions and 189 deletions

View File

@@ -22,45 +22,16 @@ export function BlogHeader() {
</span>
</Link>
{/* Desktop Navigation */}
{user && (
<nav className="hidden sm:flex items-center gap-2">
<Button variant="ghost" size="sm" asChild>
<Link to="/following">
<Users className="h-4 w-4 mr-2" />
Following
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link to="/bookmarks">
<Bookmark className="h-4 w-4 mr-2" />
Bookmarks
</Link>
</Button>
<Button variant="ghost" size="sm" asChild>
<Link to="/highlights">
<Highlighter className="h-4 w-4 mr-2" />
Highlights
</Link>
</Button>
<Button variant="outline" size="sm" asChild>
<Link to="/create">
<PenSquare className="h-4 w-4 mr-2" />
New Post
</Link>
</Button>
</nav>
)}
</div>
{/* Desktop Actions */}
<div className="hidden sm:flex items-center gap-2">
<div className="hidden lg:flex items-center gap-2">
<ThemeToggle />
<LoginArea className="max-w-60" />
</div>
{/* Mobile Menu */}
<div className="sm:hidden">
{/* Burger Menu (shown on tablets and mobile) */}
<div className="lg:hidden">
<Sheet open={isMenuOpen} onOpenChange={setIsMenuOpen}>
<SheetTrigger asChild>
<Button variant="ghost" size="icon">
@@ -69,12 +40,6 @@ export function BlogHeader() {
</SheetTrigger>
<SheetContent side="right" className="w-[280px]">
<div className="flex flex-col gap-6 mt-8">
{/* Theme Toggle in Menu */}
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Theme</span>
<ThemeToggle />
</div>
{/* Login Area in Menu */}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Account</span>
@@ -83,35 +48,39 @@ export function BlogHeader() {
{/* Navigation for logged in users */}
{user && (
<>
<div className="border-t pt-4 space-y-2">
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/following">
<Users className="h-4 w-4 mr-2" />
Following
</Link>
</Button>
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/bookmarks">
<Bookmark className="h-4 w-4 mr-2" />
Bookmarks
</Link>
</Button>
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/highlights">
<Highlighter className="h-4 w-4 mr-2" />
Highlights
</Link>
</Button>
<Button variant="default" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/create">
<PenSquare className="h-4 w-4 mr-2" />
New Post
</Link>
</Button>
</div>
</>
<div className="border-t pt-4 space-y-2">
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/following">
<Users className="h-4 w-4 mr-2" />
Following
</Link>
</Button>
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/bookmarks">
<Bookmark className="h-4 w-4 mr-2" />
Bookmarks
</Link>
</Button>
<Button variant="outline" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/highlights">
<Highlighter className="h-4 w-4 mr-2" />
Highlights
</Link>
</Button>
<Button variant="default" className="w-full" asChild onClick={() => setIsMenuOpen(false)}>
<Link to="/create">
<PenSquare className="h-4 w-4 mr-2" />
New Post
</Link>
</Button>
</div>
)}
{/* Theme Toggle in Menu */}
<div className="border-t pt-4 flex items-center justify-between">
<span className="text-sm font-medium">Theme</span>
<ThemeToggle />
</div>
</div>
</SheetContent>
</Sheet>

View File

@@ -15,7 +15,7 @@ interface MarkdownContentProps {
*/
export function MarkdownContent({ content, className }: MarkdownContentProps) {
return (
<div className={cn('prose prose-slate dark:prose-invert prose-headings:font-bold prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl prose-h4:text-xl prose-h5:text-lg prose-h6:text-base max-w-none break-words overflow-wrap-anywhere', className)}>
<div className={cn('prose prose-slate dark:prose-invert prose-headings:font-bold prose-h1:text-4xl prose-h2:text-3xl prose-h3:text-2xl prose-h4:text-xl prose-h5:text-lg prose-h6:text-base max-w-none break-words overflow-wrap-anywhere select-text', className)}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{

View File

@@ -0,0 +1,130 @@
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { getHighlightSource, getHighlightComment, isQuoteHighlight } from '@/lib/validators';
import { useAuthor } from '@/hooks/useAuthor';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { ExternalLink, MessageSquare } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
interface HighlightCardProps {
highlight: {
id: string;
pubkey: string;
content: string;
created_at: number;
tags: string[][];
};
}
/**
* Card component for displaying a highlight with author, content, and source link
* Used in HighlightsPage and potentially profile pages
*/
export function HighlightCard({ highlight }: HighlightCardProps) {
const source = getHighlightSource(highlight as never);
const comment = getHighlightComment(highlight as never);
const isQuote = isQuoteHighlight(highlight as never);
const { data: author } = useAuthor(highlight.pubkey);
const metadata = author?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(highlight.pubkey);
const avatarUrl = metadata?.picture;
// Parse source to get article link
let articleLink: string | null = null;
if (source?.type === 'address') {
// Parse a-tag: "30023:pubkey:d-tag"
const parts = source.value.split(':');
if (parts.length === 3) {
const [kind, pubkey, identifier] = parts;
const naddr = nip19.naddrEncode({
kind: parseInt(kind),
pubkey: pubkey,
identifier: identifier,
});
articleLink = `/${naddr}`;
}
} else if (source?.type === 'url') {
articleLink = source.value;
}
const date = new Date(highlight.created_at * 1000);
return (
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<Link
to={`/${nip19.npubEncode(highlight.pubkey)}`}
className="font-medium text-sm hover:underline truncate block"
>
{displayName}
</Link>
<time className="text-xs text-muted-foreground">
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</div>
</div>
{isQuote && (
<Badge variant="secondary" className="flex-shrink-0">
<MessageSquare className="h-3 w-3 mr-1" />
Quote
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Highlighted text */}
<div className="bg-amber-50 dark:bg-amber-950/20 border-l-4 border-amber-500 p-3 rounded-r">
<p className="text-sm italic">&ldquo;{highlight.content}&rdquo;</p>
</div>
{/* Comment if it's a quote highlight */}
{comment && (
<div className="pl-4 border-l-2 border-muted">
<p className="text-sm text-muted-foreground">{comment}</p>
</div>
)}
{/* Link to source */}
{articleLink && (
<div className="pt-2">
{source?.type === 'address' ? (
<Link
to={articleLink}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View article
</Link>
) : (
<a
href={articleLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View source
</a>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -3,121 +3,8 @@ import { useUserHighlights } from '@/hooks/useUserHighlights';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { LoginArea } from '@/components/auth/LoginArea';
import { Highlighter, ExternalLink, MessageSquare } from 'lucide-react';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { getHighlightSource, getHighlightComment, isQuoteHighlight } from '@/lib/validators';
import { useAuthor } from '@/hooks/useAuthor';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { genUserName } from '@/lib/genUserName';
function HighlightCard({ highlight }: { highlight: { id: string; pubkey: string; content: string; created_at: number; tags: string[][] } }) {
const source = getHighlightSource(highlight as never);
const comment = getHighlightComment(highlight as never);
const isQuote = isQuoteHighlight(highlight as never);
const { data: author } = useAuthor(highlight.pubkey);
const metadata = author?.metadata;
const displayName = metadata?.name || metadata?.display_name || genUserName(highlight.pubkey);
const avatarUrl = metadata?.picture;
// Parse source to get article link
let articleLink: string | null = null;
if (source?.type === 'address') {
// Parse a-tag: "30023:pubkey:d-tag"
const parts = source.value.split(':');
if (parts.length === 3) {
const [kind, pubkey, identifier] = parts;
const naddr = nip19.naddrEncode({
kind: parseInt(kind),
pubkey: pubkey,
identifier: identifier,
});
articleLink = `/${naddr}`;
}
} else if (source?.type === 'url') {
articleLink = source.value;
}
const date = new Date(highlight.created_at * 1000);
return (
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Avatar className="h-8 w-8 flex-shrink-0">
<AvatarImage src={avatarUrl} alt={displayName} />
<AvatarFallback>{displayName[0]?.toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<Link
to={`/${nip19.npubEncode(highlight.pubkey)}`}
className="font-medium text-sm hover:underline truncate block"
>
{displayName}
</Link>
<time className="text-xs text-muted-foreground">
{date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</div>
</div>
{isQuote && (
<Badge variant="secondary" className="flex-shrink-0">
<MessageSquare className="h-3 w-3 mr-1" />
Quote
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Highlighted text */}
<div className="bg-amber-50 dark:bg-amber-950/20 border-l-4 border-amber-500 p-3 rounded-r">
<p className="text-sm italic">&ldquo;{highlight.content}&rdquo;</p>
</div>
{/* Comment if it's a quote highlight */}
{comment && (
<div className="pl-4 border-l-2 border-muted">
<p className="text-sm text-muted-foreground">{comment}</p>
</div>
)}
{/* Link to source */}
{articleLink && (
<div className="pt-2">
{source?.type === 'address' ? (
<Link
to={articleLink}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View article
</Link>
) : (
<a
href={articleLink}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<ExternalLink className="h-3 w-3" />
View source
</a>
)}
</div>
)}
</CardContent>
</Card>
);
}
import { Highlighter } from 'lucide-react';
import { HighlightCard } from '@/components/highlights/HighlightCard';
function HighlightsSkeleton() {
return (

View File

@@ -3,16 +3,18 @@ import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts';
import { useUserBookmarkedArticles } from '@/hooks/useUserBookmarkedArticles';
import { useUserHighlights } from '@/hooks/useUserHighlights';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Link2, Mail, Copy, Check, Bookmark } from 'lucide-react';
import { Link2, Mail, Copy, Check, Bookmark, Highlighter } from 'lucide-react';
import { genUserName } from '@/lib/genUserName';
import { RelaySelector } from '@/components/RelaySelector';
import { ArticlePreview } from '@/components/ArticlePreview';
import { HighlightCard } from '@/components/highlights/HighlightCard';
import { useToast } from '@/hooks/useToast';
import NotFound from '@/pages/NotFound';
import { useState } from 'react';
@@ -47,6 +49,7 @@ export default function ProfilePage() {
const author = useAuthor(pubkey);
const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey);
const { data: bookmarkedArticles, isLoading: bookmarksLoading } = useUserBookmarkedArticles(pubkey);
const { data: highlights, isLoading: highlightsLoading } = useUserHighlights(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
@@ -216,24 +219,33 @@ export default function ProfilePage() {
{/* Content Tabs */}
<div className="mt-8 px-4 md:px-0">
<Tabs defaultValue="articles" className="w-full">
<TabsList className="w-full md:w-auto">
<TabsTrigger value="articles" className="flex-1 md:flex-initial">
Published Articles
<TabsList className="w-full md:w-auto grid grid-cols-3">
<TabsTrigger value="articles">
Published
{posts && posts.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{posts.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="bookmarks" className="flex-1 md:flex-initial">
<Bookmark className="h-4 w-4 mr-2" />
Bookmarks
<TabsTrigger value="bookmarks">
<Bookmark className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Bookmarks</span>
{bookmarkedArticles && bookmarkedArticles.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{bookmarkedArticles.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="highlights">
<Highlighter className="h-4 w-4 mr-1" />
<span className="hidden sm:inline">Highlights</span>
{highlights && highlights.length > 0 && (
<Badge variant="secondary" className="ml-2 text-xs">
{highlights.length}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* Published Articles Tab */}
@@ -307,6 +319,51 @@ export default function ProfilePage() {
</Card>
)}
</TabsContent>
{/* Highlights Tab */}
<TabsContent value="highlights" className="space-y-6">
{highlightsLoading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Card key={i}>
<CardHeader>
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))}
</div>
) : highlights && highlights.length > 0 ? (
<div className="space-y-4">
{highlights.map((highlight) => (
<HighlightCard key={highlight.id} highlight={highlight} />
))}
</div>
) : (
<Card className="border-dashed">
<CardContent className="py-12 px-8 text-center">
<div className="max-w-sm mx-auto space-y-6">
<Highlighter className="h-16 w-16 mx-auto text-muted-foreground" />
<div className="space-y-2">
<h3 className="text-xl font-semibold">No Highlights</h3>
<p className="text-muted-foreground">
This user hasn't created any highlights yet.
</p>
</div>
<RelaySelector className="w-full" />
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
</div>