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:
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user