mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-04-26 06:48:06 +02:00
Add highlights page and navigation
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import EditPostPage from "./pages/EditPostPage";
|
||||
import SearchResultsPage from "./pages/SearchResultsPage";
|
||||
import { BookmarksPage } from "./pages/BookmarksPage";
|
||||
import FollowingPage from "./pages/FollowingPage";
|
||||
import HighlightsPage from "./pages/HighlightsPage";
|
||||
import { NIP19Page } from "./pages/NIP19Page";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
@@ -23,6 +24,7 @@ export function AppRouter() {
|
||||
<Route path="/search" element={<SearchResultsPage />} />
|
||||
<Route path="/bookmarks" element={<BookmarksPage />} />
|
||||
<Route path="/following" element={<FollowingPage />} />
|
||||
<Route path="/highlights" element={<HighlightsPage />} />
|
||||
{/* NIP-19 route for all Nostr identifiers (npub, nprofile, naddr, note, nevent) */}
|
||||
<Route path="/:nip19" element={<NIP19Page />} />
|
||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { LoginArea } from '@/components/auth/LoginArea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PenSquare, Menu, Bookmark, Users } from 'lucide-react';
|
||||
import { PenSquare, Menu, Bookmark, Users, Highlighter } from 'lucide-react';
|
||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { useState } from 'react';
|
||||
@@ -37,6 +37,12 @@ export function BlogHeader() {
|
||||
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" />
|
||||
@@ -91,6 +97,12 @@ export function BlogHeader() {
|
||||
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" />
|
||||
|
||||
216
src/pages/HighlightsPage.tsx
Normal file
216
src/pages/HighlightsPage.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function HighlightsSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HighlightsPage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: highlights, isLoading } = useUserHighlights(user?.pubkey || '', {
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="container max-w-4xl py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="text-center space-y-8">
|
||||
<div className="space-y-4">
|
||||
<Highlighter className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||
<h1 className="text-3xl font-bold">Your Highlights</h1>
|
||||
<p className="text-muted-foreground max-w-md mx-auto">
|
||||
Sign in to view and manage your saved highlights from articles across Nostr
|
||||
</p>
|
||||
</div>
|
||||
<LoginArea className="flex justify-center" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="container max-w-4xl py-8 px-4 sm:px-6 lg:px-8">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-3xl font-bold flex items-center gap-3">
|
||||
<Highlighter className="h-8 w-8" />
|
||||
My Highlights
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Your collection of valuable insights and passages from articles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{isLoading && <HighlightsSkeleton />}
|
||||
|
||||
{/* Highlights list */}
|
||||
{!isLoading && highlights && highlights.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{highlights.map((highlight) => (
|
||||
<HighlightCard key={highlight.id} highlight={highlight} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && (!highlights || highlights.length === 0) && (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center space-y-4">
|
||||
<Highlighter className="h-12 w-12 mx-auto text-muted-foreground" />
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg font-medium">No highlights yet</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md mx-auto">
|
||||
Start highlighting valuable passages from articles to build your collection of insights.
|
||||
Select any text in an article and click “Highlight” to get started.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user