Add comprehensive SEO optimization with dynamic meta tags and social sharing support (#23)

* Initial plan

* Add SEO optimization with dynamic meta tags for all pages

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Fix SEO meta tags to use useSeoMeta correctly without useEffect

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Add SEO verification documentation

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Add comprehensive SEO examples documentation

Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>

* Refactor routing to use HomePage component and update blog post fetching limit

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mroxso <24775431+mroxso@users.noreply.github.com>
Co-authored-by: highperfocused <highperfocused@pm.me>
This commit is contained in:
Copilot
2025-10-19 14:20:46 +02:00
committed by GitHub
parent c37dd97d0f
commit 568f10761e
18 changed files with 676 additions and 18 deletions

253
SEO_EXAMPLES.md Normal file
View File

@@ -0,0 +1,253 @@
# SEO Implementation Examples
This document shows concrete examples of how the SEO meta tags appear on different pages of zelo.news.
## Example 1: Article Page
When viewing an article titled "The Future of Decentralized Social Media" by Alice:
### What Gets Rendered:
```html
<head>
<title>The Future of Decentralized Social Media - Alice - zelo.news</title>
<meta name="description" content="Exploring how Nostr and other decentralized protocols are reshaping social media. This article covers the key advantages and challenges...">
<meta name="author" content="Alice">
<!-- Open Graph for Facebook, LinkedIn, WhatsApp -->
<meta property="og:title" content="The Future of Decentralized Social Media">
<meta property="og:description" content="Exploring how Nostr and other decentralized protocols are reshaping social media...">
<meta property="og:type" content="article">
<meta property="og:url" content="https://zelo.news/naddr1...">
<meta property="og:image" content="https://example.com/article-image.jpg">
<meta property="og:site_name" content="zelo.news">
<meta property="article:published_time" content="2025-10-12T10:30:00.000Z">
<meta property="article:author" content="Alice">
<meta property="article:tag" content="nostr">
<meta property="article:tag" content="decentralization">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="The Future of Decentralized Social Media">
<meta name="twitter:description" content="Exploring how Nostr and other decentralized protocols...">
<meta name="twitter:image" content="https://example.com/article-image.jpg">
<meta name="twitter:site" content="@zelo_news">
</head>
```
### What This Looks Like When Shared:
**WhatsApp Preview:**
```
┌─────────────────────────────────────────┐
│ [Article Image] │
├─────────────────────────────────────────┤
│ The Future of Decentralized Social │
│ Media │
│ Exploring how Nostr and other │
│ decentralized protocols are reshaping │
│ social media... │
│ zelo.news │
└─────────────────────────────────────────┘
```
**Twitter/X Preview:**
```
┌─────────────────────────────────────────┐
│ [Large Article Image] │
├─────────────────────────────────────────┤
│ The Future of Decentralized Social │
│ Media │
│ Exploring how Nostr and other │
│ decentralized protocols... │
│ 🔗 zelo.news │
└─────────────────────────────────────────┘
```
## Example 2: Profile Page
When viewing Bob's profile who has published 15 articles:
### What Gets Rendered:
```html
<head>
<title>Bob - Profile - zelo.news</title>
<meta name="description" content="Bitcoin enthusiast and writer exploring decentralized systems. • 15 articles published">
<meta name="author" content="Bob">
<!-- Open Graph -->
<meta property="og:title" content="Bob on zelo.news">
<meta property="og:description" content="Bitcoin enthusiast and writer exploring decentralized systems. • 15 articles published">
<meta property="og:type" content="profile">
<meta property="og:url" content="https://zelo.news/npub1...">
<meta property="og:image" content="https://example.com/bob-avatar.jpg">
<meta property="og:site_name" content="zelo.news">
<meta property="profile:username" content="bob@nostr.com">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Bob on zelo.news">
<meta name="twitter:description" content="Bitcoin enthusiast and writer exploring decentralized systems. • 15 articles published">
<meta name="twitter:image" content="https://example.com/bob-avatar.jpg">
<meta name="twitter:site" content="@zelo_news">
</head>
```
### What This Looks Like When Shared:
**Facebook/LinkedIn Preview:**
```
┌─────────────────────────────────────────┐
│ [Bob's Profile Banner or Avatar] │
├─────────────────────────────────────────┤
│ Bob on zelo.news │
│ Bitcoin enthusiast and writer exploring │
│ decentralized systems. • 15 articles │
│ published │
│ 🔗 ZELO.NEWS │
└─────────────────────────────────────────┘
```
## Example 3: Home Page
When viewing the home page:
### What Gets Rendered:
```html
<head>
<title>zelo.news - Decentralized News on Nostr</title>
<meta name="description" content="Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.">
<meta name="keywords" content="nostr, decentralized, news, articles, blog, bitcoin, lightning, web3">
<meta name="author" content="zelo.news">
<!-- Open Graph -->
<meta property="og:title" content="zelo.news - Decentralized News on Nostr">
<meta property="og:description" content="Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://zelo.news/">
<meta property="og:image" content="https://zelo.news/icon-512.png">
<meta property="og:site_name" content="zelo.news">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="zelo.news - Decentralized News on Nostr">
<meta name="twitter:description" content="Your source for decentralized news and articles on the Nostr protocol.">
<meta name="twitter:image" content="https://zelo.news/icon-512.png">
<meta name="twitter:site" content="@zelo_news">
</head>
```
## Example 4: Search Results Page
When searching for "bitcoin":
### What Gets Rendered:
```html
<head>
<title>Search: bitcoin - zelo.news</title>
<meta name="description" content="Found 42 results for 'bitcoin' on zelo.news">
<meta name="robots" content="noindex">
<!-- Note: robots: noindex prevents search engines from indexing search results -->
</head>
```
## Example 5: Note Page (Short Post)
When viewing a short text note from Charlie:
### What Gets Rendered:
```html
<head>
<title>Charlie's note - zelo.news</title>
<meta name="description" content="Just published my thoughts on the future of decentralized identity. Check out my latest article!">
<meta name="author" content="Charlie">
<!-- Open Graph -->
<meta property="og:title" content="Note by Charlie">
<meta property="og:description" content="Just published my thoughts on the future of decentralized identity. Check out my latest article!">
<meta property="og:type" content="article">
<meta property="og:url" content="https://zelo.news/note1...">
<meta property="og:image" content="https://example.com/charlie-avatar.jpg">
<meta property="og:site_name" content="zelo.news">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Note by Charlie">
<meta name="twitter:description" content="Just published my thoughts on the future of decentralized identity...">
<meta name="twitter:image" content="https://example.com/charlie-avatar.jpg">
<meta name="twitter:site" content="@zelo_news">
</head>
```
## Benefits of This Implementation
### 1. Better Search Engine Rankings
- Unique titles for every page
- Descriptive meta descriptions
- Proper semantic HTML structure
- Keywords in meta tags
### 2. Rich Social Media Previews
When users share links on:
- **WhatsApp**: Shows article image, title, and description
- **Facebook**: Rich preview with image, title, and description
- **Twitter/X**: Large image card with content preview
- **LinkedIn**: Professional preview with article details
- **Discord**: Embedded preview with image and text
- **Slack**: Link unfurling with full preview
- **Telegram**: Rich message preview
### 3. Dynamic Content Handling
- Meta tags update automatically when content loads
- Fallback values ensure something always displays
- Social media crawlers execute JavaScript and see updated tags
### 4. Professional Appearance
- Consistent branding across all social platforms
- Author attribution on all content
- Timestamp information for articles
- Category tags for better discovery
## Testing Your Implementation
### Quick Test with Twitter
1. Copy any article URL from your deployed site
2. Paste it into a new tweet
3. Twitter will show a preview - you should see the article image, title, and description
### Full Test with Facebook Debugger
1. Go to https://developers.facebook.com/tools/debug/
2. Enter any page URL from your deployed site
3. Click "Scrape Again"
4. Review the preview - should show all meta tags and image
### Verify in Browser
1. Open any page
2. Right-click → Inspect
3. Go to Elements tab
4. Look at `<head>` section
5. Should see all meta tags listed above
## Common Issues and Solutions
### Issue: Social preview not updating
**Solution**: Use the social media debuggers to force a re-scrape
- Facebook: https://developers.facebook.com/tools/debug/
- Twitter: https://cards-dev.twitter.com/validator
- LinkedIn: https://www.linkedin.com/post-inspector/
### Issue: Image not showing in preview
**Solution**: Ensure images use absolute URLs (https://...) not relative (/image.jpg)
### Issue: Old title/description showing
**Solution**: Clear browser cache or test in incognito mode
## Conclusion
The SEO implementation provides:
✅ Professional social media previews on all platforms
✅ Better search engine visibility
✅ Dynamic updates for Nostr content
✅ Proper handling of images and descriptions
✅ Consistent branding across the web
All without requiring server-side rendering - it works entirely in the browser with the @unhead library!

152
SEO_VERIFICATION.md Normal file
View File

@@ -0,0 +1,152 @@
# SEO Optimization Verification
This document describes the SEO optimizations implemented in zelo.news and how to verify they work correctly.
## What Was Implemented
### 1. Dynamic Page Titles
Each page now has a unique, descriptive title that updates based on content:
- **Article Pages**: `[Article Title] - [Author Name] - zelo.news`
- **Profile Pages**: `[Username] - Profile - zelo.news`
- **Home Page**: `zelo.news - Decentralized News on Nostr`
- **Search Pages**: `Search: [query] - zelo.news` or `Articles tagged #[tag] - zelo.news`
- **Other Pages**: Descriptive titles for bookmarks, following, etc.
### 2. Meta Descriptions
Every page includes a relevant description:
- **Articles**: Uses the article summary, or first 160 characters of content
- **Profiles**: Uses the user's "about" text, or a default description
- **Notes/Events**: Uses the first 160 characters of the content
- **Search**: Includes result count and search term
### 3. Open Graph Tags (for Facebook, LinkedIn, WhatsApp, etc.)
All pages include Open Graph meta tags:
- `og:title` - Page/content title
- `og:description` - Page/content description
- `og:type` - "article" for content pages, "website" for home
- `og:url` - Current page URL
- `og:image` - Article image, profile picture, or default icon
- `og:site_name` - "zelo.news"
Article pages also include:
- `article:published_time` - Publication timestamp
- `article:author` - Author name
- `article:tag` - Article hashtags
### 4. Twitter Card Tags
All pages include Twitter Card meta tags for Twitter/X sharing:
- `twitter:card` - "summary_large_image" for articles, "summary" for others
- `twitter:title` - Page/content title
- `twitter:description` - Page/content description
- `twitter:image` - Article image, profile picture, or default icon
- `twitter:site` - "@zelo_news"
### 5. Additional SEO Features
- Keywords meta tag in base HTML
- Author meta tag in base HTML
- `robots: noindex` for personal pages (bookmarks, following, search results, editor)
- Proper semantic HTML structure
## How to Verify
### Method 1: Browser DevTools
1. Open the application in a browser
2. Open DevTools (F12)
3. Go to the Elements/Inspector tab
4. Look at the `<head>` section
5. You should see dynamically injected meta tags from @unhead
### Method 2: View Page Source
1. Right-click on any page and select "View Page Source"
2. Look for `<meta>` tags in the `<head>`
3. Default tags will be visible in the static HTML
4. Dynamic tags are injected by JavaScript after page load
### Method 3: Social Media Sharing Debuggers
These tools show what social media platforms will see:
**Facebook/Open Graph Debugger:**
- Visit: https://developers.facebook.com/tools/debug/
- Enter a page URL from your deployed site
- Click "Scrape Again" to see the latest meta tags
- Should show article title, description, and image
**Twitter Card Validator:**
- Visit: https://cards-dev.twitter.com/validator
- Enter a page URL from your deployed site
- Should show a preview of how the link will appear on Twitter
**LinkedIn Post Inspector:**
- Visit: https://www.linkedin.com/post-inspector/
- Enter a page URL from your deployed site
- Should show how the link will appear on LinkedIn
### Method 4: Browser Extensions
Install a meta tag viewer extension:
- **SEO Meta in 1 Click** (Chrome/Edge)
- **META SEO inspector** (Firefox)
- These will show all meta tags on the current page
## Example: Article Page Meta Tags
When viewing an article page, the following meta tags should be present:
```html
<title>[Article Title] - [Author Name] - zelo.news</title>
<meta name="description" content="[Article summary or first 160 chars]">
<meta name="author" content="[Author Name]">
<!-- Open Graph -->
<meta property="og:title" content="[Article Title]">
<meta property="og:description" content="[Article summary or first 160 chars]">
<meta property="og:type" content="article">
<meta property="og:url" content="[Current URL]">
<meta property="og:image" content="[Article image or default icon]">
<meta property="og:site_name" content="zelo.news">
<meta property="article:published_time" content="[ISO timestamp]">
<meta property="article:author" content="[Author Name]">
<meta property="article:tag" content="[hashtag1]">
<meta property="article:tag" content="[hashtag2]">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="[Article Title]">
<meta name="twitter:description" content="[Article summary or first 160 chars]">
<meta name="twitter:image" content="[Article image or default icon]">
<meta name="twitter:site" content="@zelo_news">
```
## Testing Dynamic Content Loading
The SEO implementation handles dynamically loaded content:
1. **Initial Load**: Default meta tags from `index.html` are visible
2. **After Data Loads**: Meta tags are updated with actual content
3. **Social Media Crawlers**: They execute JavaScript and will see the updated tags
To verify this works:
1. Open a page (e.g., an article page)
2. Open DevTools > Network tab
3. Reload the page
4. Watch the meta tags in the Elements tab - they should update as data loads
## Notes
- **Server-Side Rendering**: Currently not implemented. Social media crawlers execute JavaScript, so they will see the dynamically set meta tags.
- **@unhead Library**: This library is SSR-compatible and properly manages meta tags.
- **Image URLs**: Uses absolute URLs so social media platforms can fetch images.
- **Default Fallbacks**: All pages have sensible defaults if content is not available.
## Files Modified
- `index.html` - Updated default meta tags
- `src/pages/ArticlePage.tsx` - Added article-specific SEO
- `src/pages/ProfilePage.tsx` - Added profile-specific SEO
- `src/pages/BlogHomePage.tsx` - Added home page SEO
- `src/pages/SearchResultsPage.tsx` - Added search page SEO (noindex)
- `src/pages/BookmarksPage.tsx` - Added bookmarks page SEO (noindex)
- `src/pages/FollowingPage.tsx` - Added following page SEO (noindex)
- `src/pages/CreatePostPage.tsx` - Added create page SEO (noindex)
- `src/pages/EditPostPage.tsx` - Added edit page SEO (noindex)
- `src/pages/NotePage.tsx` - Added note page SEO
- `src/pages/EventPage.tsx` - Added event page SEO

View File

@@ -3,8 +3,10 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>zelo.news - Your Source for Decentralized News</title>
<meta name="description" content="Your Source for Decentralized News" />
<title>zelo.news - Decentralized News on Nostr</title>
<meta name="description" content="Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network." />
<meta name="keywords" content="nostr, decentralized, news, articles, blog, bitcoin, lightning, web3" />
<meta name="author" content="zelo.news" />
<!-- PWA Meta Tags -->
<meta name="theme-color" content="#000000" />
@@ -17,8 +19,15 @@
<!-- Open Graph -->
<meta property="og:type" content="website" />
<meta property="og:title" content="zelo.news - Your Source for Decentralized News" />
<meta property="og:description" content="Your Source for Decentralized News" />
<meta property="og:title" content="zelo.news - Decentralized News on Nostr" />
<meta property="og:description" content="Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network." />
<meta property="og:site_name" content="zelo.news" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="zelo.news - Decentralized News on Nostr" />
<meta name="twitter:description" content="Your source for decentralized news and articles on the Nostr protocol." />
<meta name="twitter:site" content="@zelo_news" />
<meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' https:; font-src 'self'; base-uri 'self'; manifest-src 'self'; connect-src 'self' blob: https: wss:; img-src 'self' data: blob: https:; media-src 'self' https:">
</head>

View File

@@ -1,8 +1,6 @@
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { ScrollToTop } from "./components/ScrollToTop";
import { BlogLayout } from "./components/BlogLayout";
import BlogHomePage from "./pages/BlogHomePage";
import CreatePostPage from "./pages/CreatePostPage";
import EditPostPage from "./pages/EditPostPage";
import SearchResultsPage from "./pages/SearchResultsPage";
@@ -12,6 +10,7 @@ import Nip05ProfilePage from "./pages/Nip05ProfilePage";
import ArticleByDTagPage from "./pages/ArticleByDTagPage";
import { NIP19Page } from "./pages/NIP19Page";
import NotFound from "./pages/NotFound";
import HomePage from "./pages/HomePage";
export function AppRouter() {
return (
@@ -19,7 +18,7 @@ export function AppRouter() {
<ScrollToTop />
<BlogLayout>
<Routes>
<Route path="/" element={<BlogHomePage />} />
<Route path="/" element={<HomePage />} />
<Route path="/create" element={<CreatePostPage />} />
<Route path="/edit/:identifier" element={<EditPostPage />} />
<Route path="/search" element={<SearchResultsPage />} />

View File

@@ -15,7 +15,7 @@ const INITIAL_POSTS_COUNT = 3;
export function LatestInHashtag({ hashtag, icon }: LatestInHashtagProps) {
const navigate = useNavigate();
const { data: posts, isLoading } = useBlogPostsByHashtag(hashtag);
const { data: posts, isLoading } = useBlogPostsByHashtag(hashtag, 4);
// Loading state
if (isLoading) {
@@ -60,9 +60,9 @@ export function LatestInHashtag({ hashtag, icon }: LatestInHashtagProps) {
<h2 className="text-3xl font-bold tracking-tight">
Latest in #{hashtag}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{/* <p className="text-sm text-muted-foreground mt-1">
{posts.length} {posts.length === 1 ? 'article' : 'articles'} in this category
</p>
</p> */}
</div>
{hasMore && (
<Button

View File

@@ -26,7 +26,7 @@ function validateBlogPost(event: NostrEvent): event is BlogPost {
/**
* Hook to fetch blog posts filtered by a specific hashtag
*/
export function useBlogPostsByHashtag(hashtag: string) {
export function useBlogPostsByHashtag(hashtag: string, limit: number = 50) {
const { nostr } = useNostr();
return useQuery({
@@ -38,7 +38,7 @@ export function useBlogPostsByHashtag(hashtag: string) {
[{
kinds: [30023],
'#t': [hashtag.toLowerCase()],
limit: 50,
limit: limit,
}],
{ signal }
);

View File

@@ -31,7 +31,7 @@ export function useSearch(searchTerm: string, enabled = true) {
{
kinds: [30023],
'#t': [tagValue],
limit: 100,
// limit: 100,
},
],
{ signal }
@@ -45,7 +45,7 @@ export function useSearch(searchTerm: string, enabled = true) {
[
{
kinds: [0, 30023],
limit: 100,
// limit: 100,
},
],
{ signal }

View File

@@ -1,9 +1,12 @@
import { useParams } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { useBlogPost } from '@/hooks/useBlogPost';
import { useAuthor } from '@/hooks/useAuthor';
import { ArticleView } from '@/components/ArticleView';
import { Skeleton } from '@/components/ui/skeleton';
import NotFound from '@/pages/NotFound';
import { genUserName } from '@/lib/genUserName';
export default function ArticlePage() {
const { nip19: naddr } = useParams<{ nip19: string }>();
@@ -28,6 +31,58 @@ export default function ArticlePage() {
}
const { data: post, isLoading } = useBlogPost(pubkey, identifier);
const author = useAuthor(pubkey);
// Extract article metadata
const title = post?.tags.find(([name]) => name === 'title')?.[1] || 'Article';
const summary = post?.tags.find(([name]) => name === 'summary')?.[1];
const image = post?.tags.find(([name]) => name === 'image')?.[1];
const publishedAt = post?.tags.find(([name]) => name === 'published_at')?.[1];
const hashtags = post?.tags
.filter(([name]) => name === 't')
.map(([, value]) => value) || [];
const metadata = author.data?.metadata;
const authorName = metadata?.display_name || metadata?.name || genUserName(pubkey);
const date = publishedAt
? new Date(parseInt(publishedAt) * 1000)
: post ? new Date(post.created_at * 1000) : new Date();
// Set SEO meta tags when post data is available
const siteUrl = window.location.origin;
const articleUrl = window.location.href;
// Create a description from summary or content
const description = post && (summary ||
(post.content.length > 160
? post.content.substring(0, 157) + '...'
: post.content)) || 'Article on zelo.news';
useSeoMeta({
title: post && isValidNaddr ? `${title} - ${authorName} - zelo.news` : 'Article - zelo.news',
description,
author: authorName,
// Open Graph tags for social sharing
ogTitle: title,
ogDescription: description,
ogType: 'article',
ogUrl: articleUrl,
ogImage: image || `${siteUrl}/icon-512.png`,
ogSiteName: 'zelo.news',
// Article-specific OG tags
...(post && isValidNaddr && {
articlePublishedTime: date.toISOString(),
articleAuthor: [authorName],
...(hashtags.length > 0 && { articleTag: hashtags }),
}),
// Twitter Card tags
twitterCard: 'summary_large_image',
twitterTitle: title,
twitterDescription: description,
twitterImage: image || `${siteUrl}/icon-512.png`,
twitterSite: '@zelo_news',
});
if (!isValidNaddr || !naddr || kind !== 30023) {
return <NotFound />;

View File

@@ -1,3 +1,4 @@
import { useSeoMeta } from '@unhead/react';
import { ArticlePreview } from '@/components/ArticlePreview';
import { RelaySelector } from '@/components/RelaySelector';
import { LoginArea } from '@/components/auth/LoginArea';
@@ -13,6 +14,12 @@ export function BookmarksPage() {
const { data: bookmarks = [], isLoading: isLoadingBookmarks } = useBookmarks();
const { data: articles = [], isLoading: isLoadingArticles } = useBookmarkedArticles();
useSeoMeta({
title: 'My Bookmarks - zelo.news',
description: 'View your saved articles and bookmarks on zelo.news',
robots: 'noindex', // Don't index personal bookmarks pages
});
const isLoading = isLoadingBookmarks || isLoadingArticles;
return (

View File

@@ -1,6 +1,13 @@
import { useSeoMeta } from '@unhead/react';
import { ProfessionalBlogPostForm } from '@/components/ProfessionalBlogPostForm';
export default function CreatePostPage() {
useSeoMeta({
title: 'Create Article - zelo.news',
description: 'Write and publish a new article on the Nostr network',
robots: 'noindex', // Don't index editor pages
});
return (
<div className="container max-w-7xl py-6 px-4 sm:px-6 lg:px-8">
<ProfessionalBlogPostForm />

View File

@@ -1,9 +1,16 @@
import { useParams, Navigate } from 'react-router-dom';
import { useSeoMeta } from '@unhead/react';
import { ProfessionalBlogPostForm } from '@/components/ProfessionalBlogPostForm';
export default function EditPostPage() {
const { identifier } = useParams<{ identifier: string }>();
useSeoMeta({
title: 'Edit Article - zelo.news',
description: 'Edit your article on the Nostr network',
robots: 'noindex', // Don't index editor pages
});
if (!identifier) {
return <Navigate to="/" replace />;
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import { useAuthor } from '@/hooks/useAuthor';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
@@ -54,6 +55,38 @@ export function EventPage({ eventId, relayHints, authorPubkey, kind }: EventPage
const displayName = metadata?.display_name || metadata?.name || genUserName(event?.pubkey || '');
const profileImage = metadata?.picture;
// Set SEO meta tags when event data is available
const siteUrl = window.location.origin;
const eventUrl = window.location.href;
// Create a description from event content
const description = event && event.content
? (event.content.length > 160
? event.content.substring(0, 157) + '...'
: event.content)
: event
? `Kind ${event.kind} event by ${displayName} on zelo.news`
: 'Event on zelo.news';
useSeoMeta({
title: event ? `Event (kind ${event.kind}) by ${displayName} - zelo.news` : 'Event - zelo.news',
description,
author: displayName,
// Open Graph tags for social sharing
ogTitle: event ? `Kind ${event.kind} event by ${displayName}` : 'Event on zelo.news',
ogDescription: description,
ogType: 'article',
ogUrl: eventUrl,
ogImage: profileImage || `${siteUrl}/icon-512.png`,
ogSiteName: 'zelo.news',
// Twitter Card tags
twitterCard: 'summary',
twitterTitle: event ? `Kind ${event.kind} event by ${displayName}` : 'Event on zelo.news',
twitterDescription: description,
twitterImage: profileImage || `${siteUrl}/icon-512.png`,
twitterSite: '@zelo_news',
});
if (isLoading) {
return (
<div className="min-h-screen">

View File

@@ -1,3 +1,4 @@
import { useSeoMeta } from '@unhead/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useFollowingBlogPosts } from '@/hooks/useFollowingBlogPosts';
import { ArticlePreview } from '@/components/ArticlePreview';
@@ -11,6 +12,12 @@ export default function FollowingPage() {
const { user } = useCurrentUser();
const { data: posts = [], isLoading, isError } = useFollowingBlogPosts();
useSeoMeta({
title: 'Following - zelo.news',
description: 'Read articles from people you follow on zelo.news',
robots: 'noindex', // Don't index personal following feeds
});
// Show login prompt if user is not logged in
if (!user) {
return (

View File

@@ -1,10 +1,26 @@
import { useSeoMeta } from '@unhead/react';
import { SearchBar } from '@/components/SearchBar';
import { LatestArticles } from '@/components/LatestArticles';
import { LatestInHashtag } from '@/components/LatestInHashtag';
import { TrendingTags } from '@/components/TrendingTags';
import { Music, Leaf, BrainCircuit, Bitcoin } from 'lucide-react';
import { Music, Leaf, BrainCircuit, Bitcoin, Newspaper } from 'lucide-react';
export default function BlogHomePage() {
export default function HomePage() {
useSeoMeta({
title: 'zelo.news - Decentralized News on Nostr',
description: 'Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.',
ogTitle: 'zelo.news - Decentralized News on Nostr',
ogDescription: 'Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.',
ogType: 'website',
ogUrl: window.location.href,
ogImage: `${window.location.origin}/icon-512.png`,
ogSiteName: 'zelo.news',
twitterCard: 'summary_large_image',
twitterTitle: 'zelo.news - Decentralized News on Nostr',
twitterDescription: 'Your source for decentralized news and articles on the Nostr protocol.',
twitterImage: `${window.location.origin}/icon-512.png`,
twitterSite: '@zelo_news',
});
return (
<div className="min-h-screen">
@@ -20,6 +36,12 @@ export default function BlogHomePage() {
{/* Latest Articles */}
<LatestArticles />
{/* Latest in #news */}
<LatestInHashtag
hashtag="news"
icon={<Newspaper className="h-6 w-6 text-primary" />}
/>
{/* Latest in #music */}
<LatestInHashtag
hashtag="music"

View File

@@ -2,8 +2,19 @@ import { useSeoMeta } from '@unhead/react';
const Index = () => {
useSeoMeta({
title: 'Welcome to Your Blank App',
description: 'A modern Nostr client application built with React, TailwindCSS, and Nostrify.',
title: 'zelo.news - Decentralized News on Nostr',
description: 'Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.',
ogTitle: 'zelo.news - Decentralized News on Nostr',
ogDescription: 'Your source for decentralized news and articles on the Nostr protocol. Read, publish, and discover content from the Nostr network.',
ogType: 'website',
ogUrl: window.location.href,
ogImage: `${window.location.origin}/icon-512.png`,
ogSiteName: 'zelo.news',
twitterCard: 'summary_large_image',
twitterTitle: 'zelo.news - Decentralized News on Nostr',
twitterDescription: 'Your source for decentralized news and articles on the Nostr protocol.',
twitterImage: `${window.location.origin}/icon-512.png`,
twitterSite: '@zelo_news',
});
return (

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { useNostr } from '@nostrify/react';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@@ -55,6 +56,36 @@ export function NotePage({ eventId }: NotePageProps) {
react({ eventId: note.id, eventAuthor: note.pubkey });
};
// Set SEO meta tags when note data is available
const siteUrl = window.location.origin;
const noteUrl = window.location.href;
// Create a description from note content
const description = note
? (note.content.length > 160
? note.content.substring(0, 157) + '...'
: note.content)
: 'Note on zelo.news';
useSeoMeta({
title: note ? `${displayName}'s note - zelo.news` : 'Note - zelo.news',
description,
author: displayName,
// Open Graph tags for social sharing
ogTitle: `Note by ${displayName}`,
ogDescription: description,
ogType: 'article',
ogUrl: noteUrl,
ogImage: profileImage || `${siteUrl}/icon-512.png`,
ogSiteName: 'zelo.news',
// Twitter Card tags
twitterCard: 'summary',
twitterTitle: `Note by ${displayName}`,
twitterDescription: description,
twitterImage: profileImage || `${siteUrl}/icon-512.png`,
twitterSite: '@zelo_news',
});
if (isLoading) {
return (
<div className="min-h-screen">

View File

@@ -1,11 +1,13 @@
import { useParams } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { useAuthor } from '@/hooks/useAuthor';
import { useAuthorBlogPosts } from '@/hooks/useAuthorBlogPosts';
import { useUserBookmarkedArticles } from '@/hooks/useUserBookmarkedArticles';
import { ProfileView } from '@/components/ProfileView';
import { ProfileSkeleton } from '@/components/ProfileSkeleton';
import NotFound from '@/pages/NotFound';
import { genUserName } from '@/lib/genUserName';
export default function ProfilePage() {
const { nip19: npub } = useParams<{ nip19: string }>();
@@ -36,6 +38,50 @@ export default function ProfilePage() {
const { data: posts, isLoading: postsLoading } = useAuthorBlogPosts(pubkey);
const { data: bookmarkedArticles, isLoading: bookmarksLoading } = useUserBookmarkedArticles(pubkey);
const metadata = author.data?.metadata;
const displayName = metadata?.display_name || metadata?.name || genUserName(pubkey);
const about = metadata?.about;
const picture = metadata?.picture;
const banner = metadata?.banner;
const nip05 = metadata?.nip05;
// Set SEO meta tags when author data is available
const siteUrl = window.location.origin;
const profileUrl = window.location.href;
// Create a description from about or default
const description = about
? (about.length > 160 ? about.substring(0, 157) + '...' : about)
: `View ${displayName}'s profile and articles on zelo.news`;
const articleCount = posts?.length || 0;
const enrichedDescription = author.data && articleCount > 0
? `${description}${articleCount} article${articleCount !== 1 ? 's' : ''} published`
: description;
useSeoMeta({
title: author.data && isValidProfile ? `${displayName} - Profile - zelo.news` : 'Profile - zelo.news',
description: enrichedDescription,
author: displayName,
// Open Graph tags for social sharing
ogTitle: `${displayName} on zelo.news`,
ogDescription: enrichedDescription,
ogType: 'profile',
ogUrl: profileUrl,
ogImage: banner || picture || `${siteUrl}/icon-512.png`,
ogSiteName: 'zelo.news',
// Profile-specific OG tags
...(author.data && isValidProfile && {
profileUsername: nip05 || displayName,
}),
// Twitter Card tags
twitterCard: picture ? 'summary_large_image' : 'summary',
twitterTitle: `${displayName} on zelo.news`,
twitterDescription: enrichedDescription,
twitterImage: banner || picture || `${siteUrl}/icon-512.png`,
twitterSite: '@zelo_news',
});
// If not a valid profile identifier, show 404
if (!isValidProfile || !pubkey) {
return <NotFound />;

View File

@@ -1,5 +1,6 @@
import { useSearchParams, Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useSeoMeta } from '@unhead/react';
import { useSearch } from '@/hooks/useSearch';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton';
@@ -17,6 +18,24 @@ export default function SearchResultsPage() {
const { data: results, isLoading } = useSearch(searchTerm, true);
// Set SEO meta tags
const resultCount = results?.length || 0;
const isHashtagSearch = searchTerm.startsWith('#');
const title = isHashtagSearch
? `Articles tagged ${searchTerm} - zelo.news`
: `Search: ${searchTerm} - zelo.news`;
const description = isHashtagSearch
? `Browse ${resultCount} article${resultCount !== 1 ? 's' : ''} tagged with ${searchTerm} on zelo.news`
: `Found ${resultCount} result${resultCount !== 1 ? 's' : ''} for "${searchTerm}" on zelo.news`;
useSeoMeta({
title,
description,
robots: 'noindex', // Don't index search results pages
});
const profiles = results?.filter(r => r.type === 'profile') || [];
const articles = results?.filter(r => r.type === 'article') || [];