Refine profile view: rounded media tiles, accent-colored flat streak, favicons on field URLs

- Media grid items now have individual rounded-lg corners with gap spacing
- Images use sizes hint for better quality rendering at small sizes
- Streak indicator: flat style with filled flame icon using theme accent color,
  positioned between username and description, 24h window, kind 1 only
- Profile field URLs now show Google favicon next to each link with graceful fallback

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-17 03:48:07 -06:00
parent 8de69a21af
commit d8a73007de
2 changed files with 63 additions and 32 deletions
+40 -11
View File
@@ -1,12 +1,11 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect } from 'react';
import { useNostr } from '@nostrify/react';
import { useQuery } from '@tanstack/react-query';
import { Copy, Check, QrCode, ExternalLink, Bitcoin } from 'lucide-react';
import { Check, QrCode, ExternalLink, Bitcoin } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Skeleton } from '@/components/ui/skeleton';
import { useToast } from '@/hooks/useToast';
import type { NostrEvent } from '@nostrify/nostrify';
import QRCode from 'qrcode';
interface ProfileField {
@@ -53,13 +52,23 @@ function useProfileMedia(pubkey: string) {
}
}
return urls.slice(0, 9); // Max 9 items for a 3x3 grid
return urls.slice(0, 9);
},
enabled: !!pubkey,
staleTime: 5 * 60 * 1000,
});
}
/** Get favicon URL for a given website URL using Google's favicon service. */
function getFaviconUrl(url: string): string {
try {
const hostname = new URL(url).hostname;
return `https://www.google.com/s2/favicons?domain=${hostname}&sz=32`;
} catch {
return '';
}
}
/** Bitcoin QR code modal */
function BitcoinQRModal({ address }: { address: string }) {
const [qrUrl, setQrUrl] = useState('');
@@ -130,6 +139,24 @@ function BitcoinQRModal({ address }: { address: string }) {
);
}
/** Favicon image with graceful fallback. */
function Favicon({ url }: { url: string }) {
const faviconSrc = getFaviconUrl(url);
const [failed, setFailed] = useState(false);
if (!faviconSrc || failed) return null;
return (
<img
src={faviconSrc}
alt=""
className="size-4 shrink-0 rounded-sm"
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
/** A single profile field row. Handles $BTC specially. */
function ProfileFieldRow({ field }: { field: ProfileField }) {
const [copied, setCopied] = useState(false);
@@ -186,7 +213,7 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
);
}
// Regular field: label + linked value
// Regular field: label + linked value with favicon
const isUrl = field.value.startsWith('http://') || field.value.startsWith('https://');
return (
@@ -197,9 +224,10 @@ function ProfileFieldRow({ field }: { field: ProfileField }) {
href={field.value}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline truncate block"
className="flex items-center gap-1.5 text-sm text-primary hover:underline truncate mt-0.5"
>
{field.value}
<Favicon url={field.value} />
<span className="truncate">{field.value.replace(/^https?:\/\//, '')}</span>
</a>
) : (
<p className="text-sm text-muted-foreground truncate">{field.value}</p>
@@ -217,13 +245,13 @@ export function ProfileRightSidebar({ pubkey, fields }: ProfileRightSidebarProps
<section className="mb-6">
<h2 className="text-xl font-bold mb-3">Media</h2>
{mediaLoading ? (
<div className="grid grid-cols-3 gap-1 rounded-xl overflow-hidden">
<div className="grid grid-cols-3 gap-1.5">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="aspect-square" />
<Skeleton key={i} className="aspect-square rounded-lg" />
))}
</div>
) : media && media.length > 0 ? (
<div className="grid grid-cols-3 gap-1 rounded-xl overflow-hidden">
<div className="grid grid-cols-3 gap-1.5">
{media.map((url, i) => {
const isVideo = /\.(mp4|webm|mov)(\?.*)?$/i.test(url);
return (
@@ -232,7 +260,7 @@ export function ProfileRightSidebar({ pubkey, fields }: ProfileRightSidebarProps
href={url}
target="_blank"
rel="noopener noreferrer"
className="aspect-square overflow-hidden hover:opacity-80 transition-opacity"
className="aspect-square rounded-lg overflow-hidden hover:opacity-80 transition-opacity"
>
{isVideo ? (
<video
@@ -247,6 +275,7 @@ export function ProfileRightSidebar({ pubkey, fields }: ProfileRightSidebarProps
alt=""
className="w-full h-full object-cover"
loading="lazy"
sizes="96px"
/>
)}
</a>
+23 -21
View File
@@ -17,15 +17,18 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { genUserName } from '@/lib/genUserName';
import type { NostrEvent } from '@nostrify/nostrify';
const STREAK_WINDOW_HOURS = 36;
const STREAK_WINDOW_HOURS = 24;
const STREAK_DISPLAY_LIMIT = 99;
/** Calculate posting streak: consecutive posts within a 36-hour window of each other. */
/** Calculate posting streak: consecutive kind 1 posts within 24-hour windows. */
function calculateStreak(posts: NostrEvent[]): number {
if (!posts || posts.length === 0) return 0;
// Posts should already be sorted newest-first
const sorted = [...posts].sort((a, b) => b.created_at - a.created_at);
// Only count kind 1 events
const kind1Posts = posts.filter((e) => e.kind === 1);
if (kind1Posts.length === 0) return 0;
const sorted = [...kind1Posts].sort((a, b) => b.created_at - a.created_at);
const windowSeconds = STREAK_WINDOW_HOURS * 3600;
let streak = 1;
@@ -165,24 +168,23 @@ export function ProfilePage() {
</div>
</div>
<div className="flex items-center gap-3">
<div className="min-w-0">
<h2 className="text-xl font-bold">{displayName}</h2>
{metadata?.nip05 && (
<p className="text-sm text-muted-foreground truncate">@{metadata.nip05}</p>
)}
</div>
<h2 className="text-xl font-bold">{displayName}</h2>
{metadata?.nip05 && (
<p className="text-sm text-muted-foreground truncate">@{metadata.nip05}</p>
)}
{/* Streak badge */}
{streak > 1 && (
<div className="flex items-center gap-1 bg-orange-500/15 text-orange-500 px-2.5 py-1 rounded-full shrink-0" title={`${streak > STREAK_DISPLAY_LIMIT ? `${STREAK_DISPLAY_LIMIT}+` : streak} posts within ${STREAK_WINDOW_HOURS}h windows`}>
<Flame className="size-4" />
<span className="text-sm font-bold tabular-nums">
{streak > STREAK_DISPLAY_LIMIT ? `${STREAK_DISPLAY_LIMIT}+` : streak}
</span>
</div>
)}
</div>
{/* Streak indicator */}
{streak > 1 && (
<div
className="flex items-center gap-1 text-primary mt-2"
title={`${streak > STREAK_DISPLAY_LIMIT ? `${STREAK_DISPLAY_LIMIT}+` : streak} posts within ${STREAK_WINDOW_HOURS}h windows`}
>
<Flame className="size-4 fill-primary" />
<span className="text-sm font-bold tabular-nums">
{streak > STREAK_DISPLAY_LIMIT ? `${STREAK_DISPLAY_LIMIT}+` : streak}
</span>
</div>
)}
{metadata?.about && (
<p className="mt-3 text-sm whitespace-pre-wrap">{metadata.about}</p>