mirror of
https://github.com/mroxso/zelo-news.git
synced 2026-06-04 17:41:10 +02:00
Build modern homepage
This commit is contained in:
@@ -16,6 +16,38 @@ interface ArticlePreviewProps {
|
||||
featured?: boolean;
|
||||
}
|
||||
|
||||
function ArticleVisual({ image, title, featured }: { image?: string; title: string; featured?: boolean }) {
|
||||
const baseClass = featured ? 'aspect-[16/10] md:aspect-auto md:h-full md:min-h-[320px]' : 'aspect-[16/10]';
|
||||
|
||||
if (image) {
|
||||
return (
|
||||
<div className={`${baseClass} overflow-hidden bg-muted`}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${baseClass} relative overflow-hidden bg-[#101211]`}>
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_18%_12%,rgba(34,211,238,0.42),transparent_28%),radial-gradient(circle_at_80%_22%,rgba(251,146,60,0.36),transparent_30%),linear-gradient(135deg,rgba(217,249,157,0.28),transparent_46%)]" />
|
||||
<div className="absolute inset-0 opacity-20 bg-[linear-gradient(to_right,rgba(255,255,255,0.32)_1px,transparent_1px),linear-gradient(to_bottom,rgba(255,255,255,0.32)_1px,transparent_1px)] bg-[size:28px_28px]" />
|
||||
<div className="relative flex h-full items-end justify-between p-4 text-white">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-white/60">Nostr Article</p>
|
||||
<p className="mt-1 line-clamp-2 max-w-[18rem] text-lg font-semibold leading-tight">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<img src="/icon.svg" alt="" className="h-12 w-12 rounded-md shadow-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ArticlePreview({ post, variant = 'default', showAuthor = true, featured = false }: ArticlePreviewProps) {
|
||||
const { data: author } = useAuthor(post.pubkey);
|
||||
const metadata = author?.metadata;
|
||||
@@ -64,24 +96,16 @@ export function ArticlePreview({ post, variant = 'default', showAuthor = true, f
|
||||
if (featured) {
|
||||
return (
|
||||
<Link to={`/${naddr}`}>
|
||||
<Card className="overflow-hidden hover:shadow-xl transition-shadow">
|
||||
<Card className="group overflow-hidden border-zinc-950/10 bg-white/[0.82] shadow-sm backdrop-blur transition-all duration-300 hover:-translate-y-0.5 hover:border-zinc-950/20 hover:shadow-[0_24px_80px_rgba(24,24,27,0.14)] dark:border-white/10 dark:bg-white/[0.06] dark:hover:border-white/20">
|
||||
<div className="grid md:grid-cols-2 gap-0">
|
||||
{image && (
|
||||
<div className="aspect-video md:aspect-auto overflow-hidden bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex flex-col ${!image ? 'md:col-span-2' : ''}`}>
|
||||
<ArticleVisual image={image} title={title} featured />
|
||||
<div className="flex flex-col">
|
||||
<CardHeader className="flex-1">
|
||||
<h3 className={`${titleSize} font-bold line-clamp-2 mb-3`}>
|
||||
<h3 className={`${titleSize} font-serif line-clamp-2 mb-3 leading-tight`}>
|
||||
{title}
|
||||
</h3>
|
||||
{summary && (
|
||||
<p className={`text-muted-foreground ${summaryLines} text-base`}>
|
||||
<p className={`text-muted-foreground ${summaryLines} text-base leading-7`}>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
@@ -109,7 +133,7 @@ export function ArticlePreview({ post, variant = 'default', showAuthor = true, f
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{hashtags.map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Badge key={tag} variant="secondary" className="border border-zinc-950/10 bg-lime-300/25 text-xs text-zinc-800 dark:border-white/10 dark:bg-lime-300/15 dark:text-lime-100">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
@@ -126,22 +150,14 @@ export function ArticlePreview({ post, variant = 'default', showAuthor = true, f
|
||||
// Default layout - vertical card
|
||||
return (
|
||||
<Link to={`/${naddr}`}>
|
||||
<Card className="overflow-hidden hover:shadow-lg transition-shadow h-full flex flex-col">
|
||||
{image && (
|
||||
<div className="aspect-video overflow-hidden bg-muted">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-full object-cover hover:scale-105 transition-transform duration-300"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Card className="group h-full overflow-hidden border-zinc-950/10 bg-white/[0.82] shadow-sm backdrop-blur transition-all duration-300 hover:-translate-y-0.5 hover:border-zinc-950/20 hover:shadow-[0_18px_60px_rgba(24,24,27,0.12)] dark:border-white/10 dark:bg-white/[0.06] dark:hover:border-white/20 flex flex-col">
|
||||
<ArticleVisual image={image} title={title} />
|
||||
<CardHeader className="flex-1">
|
||||
<h3 className={`${titleSize} font-bold line-clamp-2 mb-2`}>
|
||||
<h3 className={`${titleSize} font-serif line-clamp-2 mb-2 leading-tight`}>
|
||||
{title}
|
||||
</h3>
|
||||
{summary && (
|
||||
<p className={`text-muted-foreground text-sm ${summaryLines}`}>
|
||||
<p className={`text-muted-foreground text-sm leading-6 ${summaryLines}`}>
|
||||
{summary}
|
||||
</p>
|
||||
)}
|
||||
@@ -169,7 +185,7 @@ export function ArticlePreview({ post, variant = 'default', showAuthor = true, f
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{hashtags.map((tag: string) => (
|
||||
<Badge key={tag} variant="secondary" className="text-xs">
|
||||
<Badge key={tag} variant="secondary" className="border border-zinc-950/10 bg-lime-300/25 text-xs text-zinc-800 dark:border-white/10 dark:bg-lime-300/15 dark:text-lime-100">
|
||||
#{tag}
|
||||
</Badge>
|
||||
))}
|
||||
|
||||
@@ -9,19 +9,22 @@ export function Header() {
|
||||
const { user } = useCurrentUser();
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="sticky top-0 z-50 w-full border-b border-zinc-950/10 bg-[#f8f6ef]/85 backdrop-blur-xl supports-[backdrop-filter]:bg-[#f8f6ef]/75 dark:border-white/10 dark:bg-[#060807]/85 dark:supports-[backdrop-filter]:bg-[#060807]/75">
|
||||
<div className="container flex h-16 items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
{/* Logo / Brand */}
|
||||
<div className="flex items-center gap-4 sm:gap-6">
|
||||
<Link to="/" className="flex items-center gap-2 sm:gap-3 hover:opacity-80 transition-opacity">
|
||||
<span className="font-bold text-lg sm:text-xl">
|
||||
<span className="flex h-9 w-9 items-center justify-center overflow-hidden rounded-md border border-zinc-950/10 bg-zinc-950 shadow-sm dark:border-white/10">
|
||||
<img src="/icon.svg" alt="" className="h-full w-full" />
|
||||
</span>
|
||||
<span className="font-serif text-xl font-semibold sm:text-2xl">
|
||||
zelo.news
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
{user && (
|
||||
<nav className="hidden sm:flex items-center gap-2">
|
||||
<nav className="hidden items-center gap-1 rounded-lg border border-zinc-950/10 bg-white/55 p-1 shadow-sm backdrop-blur sm:flex dark:border-white/10 dark:bg-white/10">
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/following" aria-label="Following">
|
||||
<Users className="h-4 w-4" />
|
||||
@@ -48,7 +51,7 @@ export function Header() {
|
||||
|
||||
{/* Desktop Actions */}
|
||||
<div className="hidden sm:flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" asChild>
|
||||
<Button variant="ghost" size="icon" className="hover:bg-white/70 dark:hover:bg-white/10" asChild>
|
||||
<Link to="/settings" aria-label="Settings">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Link>
|
||||
@@ -65,7 +68,7 @@ export function Header() {
|
||||
</Link>
|
||||
</Button>
|
||||
<ThemeToggle />
|
||||
<LoginArea className='pl-2' />
|
||||
<LoginArea className="pl-1 [&>div]:gap-1 [&_button]:h-9 [&_button]:w-9 [&_button]:rounded-md [&_button]:px-0 [&_button_span]:sr-only" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { Card, CardHeader } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Newspaper } from 'lucide-react';
|
||||
import { ArrowRight, Newspaper } from 'lucide-react';
|
||||
import { useLongFormContentNotes } from '@/hooks/useLongFormContentNotes';
|
||||
import { ArticlePreview } from '@/components/ArticlePreview';
|
||||
|
||||
@@ -16,8 +16,10 @@ export function LatestArticles() {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 border-b pb-4">
|
||||
<Newspaper className="h-8 w-8 text-primary" />
|
||||
<div className="flex items-center gap-3 border-b border-zinc-950/10 pb-4 dark:border-white/10">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-cyan-300/20 text-cyan-700 dark:text-cyan-200">
|
||||
<Newspaper className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 mb-1" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
@@ -55,14 +57,20 @@ export function LatestArticles() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 border-b pb-4">
|
||||
<Newspaper className="h-8 w-8 text-primary" />
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Latest Articles</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discover the most recent stories from the community
|
||||
</p>
|
||||
<div className="flex items-end justify-between gap-4 border-b border-zinc-950/10 pb-4 dark:border-white/10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-lg bg-cyan-300/20 text-cyan-700 dark:text-cyan-200">
|
||||
<Newspaper className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">Fresh from the network</p>
|
||||
<h2 className="font-serif text-3xl leading-tight sm:text-4xl">Latest Articles</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discover the most recent stories from the community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="hidden h-5 w-5 text-zinc-400 sm:block" />
|
||||
</div>
|
||||
|
||||
{/* Featured Post - Full Width */}
|
||||
|
||||
@@ -82,8 +82,12 @@ export function LatestInHashtag({ hashtags, icon, title }: LatestInHashtagProps)
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3 border-b pb-4">
|
||||
{icon || <Hash className="h-8 w-8 text-primary" />}
|
||||
<div className="flex items-center gap-3 border-b border-zinc-950/10 pb-4 dark:border-white/10">
|
||||
{icon || (
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-lg bg-lime-300/20 text-lime-700 dark:text-lime-200">
|
||||
<Hash className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
<div>
|
||||
<Skeleton className="h-8 w-48 mb-1" />
|
||||
<Skeleton className="h-4 w-64" />
|
||||
@@ -117,10 +121,15 @@ export function LatestInHashtag({ hashtags, icon, title }: LatestInHashtagProps)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3 border-b pb-4">
|
||||
{icon || <Hash className="h-8 w-8 text-primary" />}
|
||||
<div className="flex items-center gap-3 border-b border-zinc-950/10 pb-4 dark:border-white/10">
|
||||
{icon || (
|
||||
<span className="flex h-11 w-11 items-center justify-center rounded-lg bg-lime-300/20 text-lime-700 dark:text-lime-200">
|
||||
<Hash className="h-5 w-5" />
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
<p className="mb-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">Topic stream</p>
|
||||
<h2 className="font-serif text-3xl leading-tight sm:text-4xl">
|
||||
{title || (hashtagArray.length === 1
|
||||
? `Latest in #${hashtagArray[0]}`
|
||||
: `Latest in ${hashtagArray.map(h => `#${h}`).join(', ')}`
|
||||
@@ -135,7 +144,7 @@ export function LatestInHashtag({ hashtags, icon, title }: LatestInHashtagProps)
|
||||
onClick={() => navigate(`/tag/${encodeURIComponent(hashtagArray[0])}`)}
|
||||
variant="outline"
|
||||
size="default"
|
||||
className="gap-1"
|
||||
className="gap-1 border-zinc-950/15 bg-white/70 backdrop-blur hover:bg-white dark:border-white/15 dark:bg-white/10 dark:hover:bg-white/15"
|
||||
>
|
||||
View All
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
|
||||
@@ -18,10 +18,18 @@ export function Layout({ children }: LayoutProps) {
|
||||
{/* Mobile-only bottom navigation */}
|
||||
<BottomNav />
|
||||
{/* Add extra bottom padding on mobile so footer content can scroll above the BottomNav overlay */}
|
||||
<footer className="border-t pt-6 pb-24 sm:pb-8 md:pt-8">
|
||||
<footer className="border-t border-zinc-950/10 bg-[#f8f6ef] pt-6 pb-24 dark:border-white/10 dark:bg-[#060807] sm:pb-8 md:pt-8">
|
||||
<div className="container px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-muted-foreground">
|
||||
<p>Powered by Nostr</p>
|
||||
<a
|
||||
href="https://soapbox.pub/mkstack"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-foreground underline-offset-4 hover:underline"
|
||||
>
|
||||
Vibed with MKStack
|
||||
</a>
|
||||
<p>Version {packageJson.version}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,9 @@ export function TrendingTags() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-300/20 text-orange-700 dark:text-orange-200">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<Skeleton className="h-7 w-48 mb-1" />
|
||||
<Skeleton className="h-4 w-32" />
|
||||
@@ -70,9 +72,11 @@ export function TrendingTags() {
|
||||
<div className="space-y-4">
|
||||
{/* Section Header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<TrendingUp className="h-6 w-6 text-primary" />
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-orange-300/20 text-orange-700 dark:text-orange-200">
|
||||
<TrendingUp className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
<h2 className="font-serif text-2xl leading-tight">
|
||||
Trending Tags
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -82,14 +86,14 @@ export function TrendingTags() {
|
||||
</div>
|
||||
|
||||
{/* Tags Card */}
|
||||
<Card>
|
||||
<Card className="border-zinc-950/10 bg-white/75 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/[0.06]">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{trendingTags.map(({ tag, count }) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-sm py-2 px-3 cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
|
||||
className="cursor-pointer border border-zinc-950/10 bg-zinc-950/[0.04] px-3 py-2 text-sm text-zinc-800 transition-colors hover:bg-zinc-950 hover:text-white dark:border-white/10 dark:bg-white/[0.06] dark:text-zinc-100 dark:hover:bg-lime-300 dark:hover:text-zinc-950"
|
||||
onClick={() => navigate(`/tag/${encodeURIComponent(tag)}`)}
|
||||
>
|
||||
<Hash className="h-3 w-3 mr-1" />
|
||||
|
||||
@@ -3,12 +3,7 @@
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
:root {
|
||||
--background: 0 0% 97.6471%;
|
||||
--foreground: 0 0% 12.5490%;
|
||||
--card: 0 0% 98.8235%;
|
||||
@@ -61,9 +56,9 @@
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
--tracking-normal: 0em;
|
||||
--spacing: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
.dark {
|
||||
--background: 0 0% 6.6667%;
|
||||
--foreground: 0 0% 93.3333%;
|
||||
--card: 0 0% 9.8039%;
|
||||
@@ -114,21 +109,8 @@
|
||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
@@ -136,4 +118,4 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { useSeoMeta } from '@unhead/react';
|
||||
import { Link } from 'react-router-dom';
|
||||
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, Newspaper, Hash } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
ArrowRight,
|
||||
Bitcoin,
|
||||
Bookmark,
|
||||
BrainCircuit,
|
||||
Hash,
|
||||
Leaf,
|
||||
Music,
|
||||
Network,
|
||||
Newspaper,
|
||||
PenSquare,
|
||||
Radio,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { useInterestSets } from '@/hooks/useInterestSets';
|
||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||
import { useAppContext } from '@/hooks/useAppContext';
|
||||
import { useLongFormContentNotes } from '@/hooks/useLongFormContentNotes';
|
||||
import { isValidDate, toISOStringSafe } from '@/lib/date';
|
||||
|
||||
export default function HomePage() {
|
||||
const { user } = useCurrentUser();
|
||||
const { data: interestSets } = useInterestSets();
|
||||
const { data: posts, isLoading: isLoadingPosts } = useLongFormContentNotes();
|
||||
const { config } = useAppContext();
|
||||
|
||||
useSeoMeta({
|
||||
@@ -31,11 +51,11 @@ export default function HomePage() {
|
||||
|
||||
// Default hashtags to show when user is logged out or has no interest sets
|
||||
const defaultHashtags = [
|
||||
{ hashtag: 'news', icon: <Newspaper className="h-6 w-6 text-primary" /> },
|
||||
{ hashtag: 'music', icon: <Music className="h-6 w-6 text-primary" /> },
|
||||
{ hashtag: 'nature', icon: <Leaf className="h-6 w-6 text-primary" /> },
|
||||
{ hashtag: 'ai', icon: <BrainCircuit className="h-6 w-6 text-primary" /> },
|
||||
{ hashtag: 'bitcoin', icon: <Bitcoin className="h-6 w-6 text-primary" /> },
|
||||
{ hashtag: 'news', icon: Newspaper, tone: 'from-cyan-400/20 to-emerald-400/10 text-cyan-700 dark:text-cyan-200' },
|
||||
{ hashtag: 'music', icon: Music, tone: 'from-fuchsia-400/20 to-orange-400/10 text-fuchsia-700 dark:text-fuchsia-200' },
|
||||
{ hashtag: 'nature', icon: Leaf, tone: 'from-emerald-400/20 to-lime-300/10 text-emerald-700 dark:text-emerald-200' },
|
||||
{ hashtag: 'ai', icon: BrainCircuit, tone: 'from-violet-400/20 to-sky-400/10 text-violet-700 dark:text-violet-200' },
|
||||
{ hashtag: 'bitcoin', icon: Bitcoin, tone: 'from-amber-400/25 to-orange-400/10 text-amber-800 dark:text-amber-100' },
|
||||
];
|
||||
|
||||
// Use interest sets if user is logged in and has sets, otherwise use defaults
|
||||
@@ -43,39 +63,236 @@ export default function HomePage() {
|
||||
? interestSets
|
||||
: null;
|
||||
|
||||
const tagCounts = new Map<string, number>();
|
||||
posts?.forEach((post) => {
|
||||
post.tags
|
||||
.filter(([name]) => name === 't')
|
||||
.map(([, value]) => value.toLowerCase())
|
||||
.forEach((tag) => tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1));
|
||||
});
|
||||
|
||||
const topTags = Array.from(tagCounts.entries())
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([tag]) => tag);
|
||||
|
||||
const latestPost = posts?.[0];
|
||||
const latestTitle = latestPost?.tags.find(([name]) => name === 'title')?.[1] ?? 'Relay feed warming up';
|
||||
const latestPublishedAt = latestPost?.tags.find(([name]) => name === 'published_at')?.[1];
|
||||
const latestDate = latestPublishedAt
|
||||
? new Date(Number.parseInt(latestPublishedAt, 10) * 1000)
|
||||
: latestPost
|
||||
? new Date(latestPost.created_at * 1000)
|
||||
: undefined;
|
||||
const latestDateTime = latestDate && isValidDate(latestDate) ? toISOStringSafe(latestDate) : undefined;
|
||||
const latestLabel = latestDate && isValidDate(latestDate)
|
||||
? latestDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
: 'Live';
|
||||
const articleCountLabel = isLoadingPosts
|
||||
? 'Live'
|
||||
: posts?.length
|
||||
? posts.length.toLocaleString()
|
||||
: 'Live';
|
||||
const topicCountLabel = isLoadingPosts
|
||||
? 'Open'
|
||||
: tagCounts.size
|
||||
? tagCounts.size.toLocaleString()
|
||||
: 'Open';
|
||||
const relayCountLabel = config.relayMetadata.relays.length.toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<div className="container max-w-6xl py-8 px-4 sm:px-6 lg:px-8 space-y-12">
|
||||
{/* Search bar */}
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<SearchBar />
|
||||
<div className="min-h-screen overflow-hidden bg-[#f8f6ef] text-zinc-950 dark:bg-[#060807] dark:text-zinc-50">
|
||||
<section className="relative border-b border-zinc-950/10 dark:border-white/10">
|
||||
<div className="absolute inset-0 -z-10 bg-[radial-gradient(circle_at_15%_20%,rgba(34,211,238,0.22),transparent_32%),radial-gradient(circle_at_85%_10%,rgba(251,146,60,0.18),transparent_28%),linear-gradient(135deg,rgba(217,249,157,0.32),transparent_42%)] dark:bg-[radial-gradient(circle_at_18%_22%,rgba(34,211,238,0.16),transparent_34%),radial-gradient(circle_at_82%_14%,rgba(251,146,60,0.14),transparent_28%),linear-gradient(135deg,rgba(132,204,22,0.12),transparent_42%)]" />
|
||||
<div className="absolute inset-0 -z-10 opacity-[0.18] dark:opacity-[0.13] bg-[linear-gradient(to_right,rgba(24,24,27,0.32)_1px,transparent_1px),linear-gradient(to_bottom,rgba(24,24,27,0.32)_1px,transparent_1px)] bg-[size:44px_44px]" />
|
||||
|
||||
<div className="container max-w-7xl px-4 py-8 sm:px-6 md:py-12 lg:px-8 lg:py-14">
|
||||
<div className="grid items-center gap-8 lg:grid-cols-[1.02fr_0.98fr] lg:gap-12">
|
||||
<div className="max-w-3xl space-y-7">
|
||||
<Badge variant="outline" className="border-zinc-950/10 bg-white/70 px-3 py-1.5 text-zinc-800 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10 dark:text-zinc-100">
|
||||
<Radio className="h-3.5 w-3.5 text-emerald-500" />
|
||||
Live from the Nostr relays
|
||||
</Badge>
|
||||
|
||||
<div className="space-y-5">
|
||||
<h1 className="font-serif text-5xl leading-[0.95] sm:text-6xl lg:text-7xl">
|
||||
zelo.news
|
||||
</h1>
|
||||
<p className="max-w-2xl text-lg leading-8 text-zinc-700 dark:text-zinc-300 sm:text-xl">
|
||||
A sharp, decentralized front page for long-form Nostr writing, live topics,
|
||||
independent authors, and the stories your relays are carrying right now.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchBar className="max-w-2xl [&_input]:h-14 [&_input]:rounded-lg [&_input]:border-zinc-950/15 [&_input]:bg-white/90 [&_input]:pl-11 [&_input]:text-base [&_input]:shadow-[0_24px_80px_rgba(24,24,27,0.12)] [&_input]:backdrop-blur dark:[&_input]:border-white/15 dark:[&_input]:bg-white/10 dark:[&_input]:shadow-[0_24px_80px_rgba(0,0,0,0.35)]" />
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Button asChild size="lg" className="h-12 bg-zinc-950 px-5 text-white hover:bg-zinc-800 dark:bg-lime-300 dark:text-zinc-950 dark:hover:bg-lime-200">
|
||||
<Link to="/create">
|
||||
<PenSquare className="h-4 w-4" />
|
||||
Publish
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="lg" variant="outline" className="h-12 border-zinc-950/15 bg-white/70 px-5 backdrop-blur hover:bg-white dark:border-white/15 dark:bg-white/10 dark:hover:bg-white/15">
|
||||
<a href="#topics">
|
||||
<Hash className="h-4 w-4" />
|
||||
Browse topics
|
||||
</a>
|
||||
</Button>
|
||||
{user && (
|
||||
<Button asChild size="lg" variant="ghost" className="h-12 px-5 hover:bg-white/70 dark:hover:bg-white/10">
|
||||
<Link to="/bookmarks">
|
||||
<Bookmark className="h-4 w-4" />
|
||||
Saved reads
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid max-w-2xl grid-cols-3 overflow-hidden rounded-lg border border-zinc-950/10 bg-white/65 shadow-sm backdrop-blur dark:border-white/10 dark:bg-white/10">
|
||||
<div className="border-r border-zinc-950/10 p-4 dark:border-white/10">
|
||||
<p className="text-2xl font-semibold tabular-nums">{articleCountLabel}</p>
|
||||
<p className="mt-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">Articles</p>
|
||||
</div>
|
||||
<div className="border-r border-zinc-950/10 p-4 dark:border-white/10">
|
||||
<p className="text-2xl font-semibold tabular-nums">{topicCountLabel}</p>
|
||||
<p className="mt-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">Topics</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<p className="text-2xl font-semibold tabular-nums">{relayCountLabel}</p>
|
||||
<p className="mt-1 text-xs uppercase text-zinc-500 dark:text-zinc-400">Relays</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div className="absolute -left-8 top-8 h-28 w-28 rounded-lg border border-zinc-950/10 bg-lime-300/80 shadow-[14px_14px_0_rgba(24,24,27,0.12)] dark:border-white/10 dark:bg-lime-300/70" />
|
||||
<div className="absolute -right-4 bottom-12 h-32 w-24 rounded-lg border border-zinc-950/10 bg-cyan-300/70 shadow-[12px_12px_0_rgba(24,24,27,0.10)] dark:border-white/10 dark:bg-cyan-300/30" />
|
||||
<div className="relative overflow-hidden rounded-lg border border-zinc-950/15 bg-zinc-950 text-white shadow-[0_32px_120px_rgba(24,24,27,0.32)] dark:border-white/15">
|
||||
<div className="flex items-center justify-between border-b border-white/10 bg-white/[0.04] px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<img src="/icon-512.png" alt="" className="h-8 w-8 rounded-md" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold">zelo relay desk</p>
|
||||
<p className="text-xs text-zinc-400">signal quality: strong</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="border-emerald-300/30 bg-emerald-300/10 text-emerald-100">
|
||||
<Zap className="h-3 w-3" />
|
||||
Synced
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-white/10 md:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="bg-[#111312] p-4 sm:p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase text-zinc-500">Latest signal</p>
|
||||
<h2 className="mt-1 line-clamp-2 text-2xl font-semibold leading-tight">
|
||||
{latestTitle}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="ml-4 flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-lime-300 text-zinc-950">
|
||||
<Newspaper className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-zinc-300">Published</span>
|
||||
{latestDateTime ? (
|
||||
<time className="text-sm font-medium" dateTime={latestDateTime}>
|
||||
{latestLabel}
|
||||
</time>
|
||||
) : (
|
||||
<span className="text-sm font-medium">{latestLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-zinc-300">Distribution</span>
|
||||
<span className="text-sm font-medium">multi-relay</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/10 bg-white/[0.04] p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm text-zinc-300">Reading mode</span>
|
||||
<span className="text-sm font-medium">long form</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#161817] p-4 sm:p-5">
|
||||
<div className="mb-4 flex items-center gap-2 text-sm font-medium">
|
||||
<Network className="h-4 w-4 text-cyan-300" />
|
||||
Trending now
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(topTags.length > 0 ? topTags : ['news', 'bitcoin', 'ai', 'music', 'nature']).map((tag, index) => (
|
||||
<Link
|
||||
key={tag}
|
||||
to={`/tag/${encodeURIComponent(tag)}`}
|
||||
className="group flex items-center justify-between rounded-lg border border-white/10 bg-white/[0.04] px-3 py-2 transition-colors hover:bg-white/[0.08]"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-md bg-white/10 text-xs text-zinc-300">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="truncate">#{tag}</span>
|
||||
</span>
|
||||
<ArrowRight className="h-3.5 w-3.5 text-zinc-500 transition-transform group-hover:translate-x-0.5 group-hover:text-lime-200" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div id="topics" className="container max-w-7xl space-y-12 px-4 py-10 sm:px-6 lg:px-8 lg:py-14">
|
||||
<div className="grid gap-4 lg:grid-cols-[0.72fr_1.28fr] lg:items-start">
|
||||
<div className="space-y-3">
|
||||
<Badge variant="outline" className="border-zinc-950/15 bg-white/65 dark:border-white/15 dark:bg-white/10">
|
||||
<Sparkles className="h-3.5 w-3.5 text-orange-500" />
|
||||
Discover
|
||||
</Badge>
|
||||
<h2 className="font-serif text-4xl leading-tight sm:text-5xl">The front page keeps moving.</h2>
|
||||
<p className="max-w-xl text-zinc-600 dark:text-zinc-300">
|
||||
Follow the conversation by topic, author, or addressable Nostr article without waiting for a central editor.
|
||||
</p>
|
||||
</div>
|
||||
<TrendingTags />
|
||||
</div>
|
||||
|
||||
{/* Trending Tags */}
|
||||
<TrendingTags />
|
||||
|
||||
{/* Latest Articles */}
|
||||
{!config.hideLatestArticles && <LatestArticles />}
|
||||
|
||||
{/* Display interest sets or default hashtags */}
|
||||
{displaySets ? (
|
||||
// User's custom interest sets - show all hashtags from each set
|
||||
displaySets
|
||||
.filter((set) => set.hashtags.length > 0)
|
||||
.map((set) => {
|
||||
return (
|
||||
<LatestInHashtag
|
||||
key={set.id}
|
||||
hashtags={set.hashtags}
|
||||
icon={<Hash className="h-6 w-6 text-primary" />}
|
||||
title={set.title}
|
||||
/>
|
||||
);
|
||||
})
|
||||
.map((set) => (
|
||||
<LatestInHashtag
|
||||
key={set.id}
|
||||
hashtags={set.hashtags}
|
||||
icon={<Hash className="h-6 w-6 text-lime-600 dark:text-lime-300" />}
|
||||
title={set.title}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Default hashtags for logged-out users or users without interest sets
|
||||
defaultHashtags.map(({ hashtag, icon }) => (
|
||||
<LatestInHashtag key={hashtag} hashtags={hashtag} icon={icon} />
|
||||
defaultHashtags.map(({ hashtag, icon: Icon, tone }) => (
|
||||
<LatestInHashtag
|
||||
key={hashtag}
|
||||
hashtags={hashtag}
|
||||
icon={
|
||||
<span className={`flex h-11 w-11 items-center justify-center rounded-lg bg-gradient-to-br ${tone}`}>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user