mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 09:31:14 +02:00
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:
@@ -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>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
130
src/components/highlights/HighlightCard.tsx
Normal file
130
src/components/highlights/HighlightCard.tsx
Normal 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">“{highlight.content}”</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>
|
||||
);
|
||||
}
|
||||
@@ -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">“{highlight.content}”</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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user