mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 04:39:12 -07:00
Bump to v0.4.0 — Phase 3: image lightbox, bookmarks, discover, language filter, UI polish
This commit is contained in:
72
src/components/bookmark/BookmarkView.tsx
Normal file
72
src/components/bookmark/BookmarkView.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { fetchNoteById } from "../../lib/nostr";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
|
||||
export function BookmarkView() {
|
||||
const { bookmarkedIds, fetchBookmarks } = useBookmarkStore();
|
||||
const { pubkey } = useUserStore();
|
||||
const [notes, setNotes] = useState<NDKEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (pubkey) fetchBookmarks(pubkey);
|
||||
}, [pubkey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (bookmarkedIds.length === 0) {
|
||||
setNotes([]);
|
||||
return;
|
||||
}
|
||||
loadNotes();
|
||||
}, [bookmarkedIds]);
|
||||
|
||||
const loadNotes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
bookmarkedIds.map((id) => fetchNoteById(id))
|
||||
);
|
||||
setNotes(
|
||||
results
|
||||
.filter((e): e is NDKEvent => e !== null)
|
||||
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<header className="border-b border-border px-4 py-2.5 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
|
||||
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && notes.length === 0 && (
|
||||
<SkeletonNoteList count={3} />
|
||||
)}
|
||||
|
||||
{!loading && notes.length === 0 && (
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">No bookmarks yet.</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">
|
||||
Use the <span className="text-accent">save</span> button on any note to bookmark it here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,17 @@ import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { fetchFollowFeed, getNDK } from "../../lib/nostr";
|
||||
import { detectScript, getEventLanguageTag, FILTER_SCRIPTS } from "../../lib/language";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
|
||||
export function Feed() {
|
||||
const { notes, loading, connected, error, connect, loadCachedFeed, loadFeed, focusedNoteIndex } = useFeedStore();
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
const { mutedPubkeys } = useMuteStore();
|
||||
const { feedTab: tab, setFeedTab: setTab } = useUIStore();
|
||||
const { feedTab: tab, setFeedTab: setTab, feedLanguageFilter, setFeedLanguageFilter } = useUIStore();
|
||||
const [followNotes, setFollowNotes] = useState<NDKEvent[]>([]);
|
||||
const [followLoading, setFollowLoading] = useState(false);
|
||||
|
||||
@@ -49,6 +51,29 @@ export function Feed() {
|
||||
// Filter out notes that look like base64 blobs or relay protocol messages
|
||||
if (c.length > 500 && /^[A-Za-z0-9+/=]{50,}$/.test(c.slice(0, 100))) return false;
|
||||
if (c.startsWith("nlogpost:") || c.startsWith("T1772")) return false;
|
||||
// Language/script filter
|
||||
if (feedLanguageFilter) {
|
||||
const langTag = getEventLanguageTag(event.tags);
|
||||
if (langTag) {
|
||||
// Map ISO-639-1 codes to script names for comparison
|
||||
const langToScript: Record<string, string> = {
|
||||
en: "Latin", es: "Latin", fr: "Latin", de: "Latin", pt: "Latin", it: "Latin", nl: "Latin", pl: "Latin", sv: "Latin", da: "Latin", no: "Latin", fi: "Latin", ro: "Latin", tr: "Latin", cs: "Latin", hr: "Latin", hu: "Latin",
|
||||
zh: "CJK", ja: "CJK",
|
||||
ko: "Korean",
|
||||
ru: "Cyrillic", uk: "Cyrillic", bg: "Cyrillic", sr: "Cyrillic",
|
||||
ar: "Arabic", fa: "Arabic", ur: "Arabic",
|
||||
hi: "Devanagari", mr: "Devanagari", ne: "Devanagari",
|
||||
th: "Thai",
|
||||
he: "Hebrew",
|
||||
el: "Greek",
|
||||
};
|
||||
const script = langToScript[langTag];
|
||||
if (script && script !== feedLanguageFilter) return false;
|
||||
} else {
|
||||
const script = detectScript(c);
|
||||
if (script !== feedLanguageFilter) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -81,6 +106,16 @@ export function Feed() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<select
|
||||
value={feedLanguageFilter ?? ""}
|
||||
onChange={(e) => setFeedLanguageFilter(e.target.value || null)}
|
||||
className="bg-transparent text-text-dim text-[11px] border border-border px-1.5 py-0.5 focus:outline-none hover:border-text-dim transition-colors cursor-pointer"
|
||||
>
|
||||
<option value="">all scripts</option>
|
||||
{FILTER_SCRIPTS.map((s) => (
|
||||
<option key={s} value={s}>{s.toLowerCase()}</option>
|
||||
))}
|
||||
</select>
|
||||
{connected && (
|
||||
<span className="text-success text-[11px] flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-success inline-block" />
|
||||
@@ -109,16 +144,25 @@ export function Feed() {
|
||||
)}
|
||||
|
||||
{isLoading && filteredNotes.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
{isFollowing ? "Loading notes from people you follow…" : "Connecting to relays…"}
|
||||
</div>
|
||||
<SkeletonNoteList count={6} />
|
||||
)}
|
||||
|
||||
{!isLoading && filteredNotes.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
{isFollowing && follows.length === 0
|
||||
? "You're not following anyone yet."
|
||||
: "No notes yet."}
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">
|
||||
{isFollowing && follows.length === 0
|
||||
? "You're not following anyone yet."
|
||||
: feedLanguageFilter
|
||||
? `No ${feedLanguageFilter} notes found.`
|
||||
: "No notes to show."}
|
||||
</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">
|
||||
{isFollowing && follows.length === 0
|
||||
? "Use search to find people to follow."
|
||||
: feedLanguageFilter
|
||||
? "Try clearing the script filter or refreshing."
|
||||
: "Try refreshing or switching tabs."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useReactionCount } from "../../hooks/useReactionCount";
|
||||
import { useZapCount } from "../../hooks/useZapCount";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
||||
import { publishReaction, publishReply, publishRepost, getNDK, fetchNoteById } from "../../lib/nostr";
|
||||
@@ -41,6 +42,8 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
const { loggedIn, pubkey: ownPubkey } = useUserStore();
|
||||
const { mutedPubkeys, mute, unmute } = useMuteStore();
|
||||
const isMuted = mutedPubkeys.includes(event.pubkey);
|
||||
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const isBookmarked = bookmarkedIds.includes(event.id!);
|
||||
const { openProfile, openThread, currentView } = useUIStore();
|
||||
|
||||
const parentEventId = getParentEventId(event);
|
||||
@@ -261,6 +264,14 @@ export function NoteCard({ event, focused }: NoteCardProps) {
|
||||
: "⚡ zap"}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => isBookmarked ? removeBookmark(event.id!) : addBookmark(event.id!)}
|
||||
className={`text-[11px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{isBookmarked ? "▪ saved" : "▫ save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useUIStore } from "../../stores/ui";
|
||||
import { fetchNoteById } from "../../lib/nostr";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
import { ImageLightbox } from "../shared/ImageLightbox";
|
||||
|
||||
// Regex patterns
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g;
|
||||
@@ -209,6 +210,7 @@ export function NoteContent({ content }: { content: string }) {
|
||||
const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value);
|
||||
const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value);
|
||||
const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const inlineElements: ReactNode[] = [];
|
||||
|
||||
@@ -284,7 +286,8 @@ export function NoteContent({ content }: { content: string }) {
|
||||
src={src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border"
|
||||
className="max-w-full max-h-80 rounded-sm object-cover bg-bg-raised border border-border cursor-zoom-in"
|
||||
onClick={(e) => { e.stopPropagation(); setLightboxIndex(i); }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
@@ -293,6 +296,15 @@ export function NoteContent({ content }: { content: string }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={images}
|
||||
index={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNavigate={setLightboxIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Quoted notes */}
|
||||
{quoteIds.map((id) => (
|
||||
<QuotePreview key={id} eventId={id} />
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useNotificationsStore } from "../../stores/notifications";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { SkeletonNoteList } from "../shared/Skeleton";
|
||||
|
||||
export function NotificationsView() {
|
||||
const { pubkey, loggedIn } = useUserStore();
|
||||
@@ -48,14 +49,13 @@ export function NotificationsView() {
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && notifications.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
Loading notifications…
|
||||
</div>
|
||||
<SkeletonNoteList count={4} />
|
||||
)}
|
||||
|
||||
{!loading && notifications.length === 0 && (
|
||||
<div className="px-4 py-8 text-text-dim text-[12px] text-center">
|
||||
No mentions yet.
|
||||
<div className="px-4 py-12 text-center space-y-2">
|
||||
<p className="text-text-dim text-[13px]">No mentions yet.</p>
|
||||
<p className="text-text-dim text-[11px] opacity-60">When someone mentions you, it will appear here.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls } from "../../lib/nostr";
|
||||
import { searchNotes, searchUsers, getStoredRelayUrls, fetchFollowSuggestions, fetchProfile } from "../../lib/nostr";
|
||||
import { getNip50Relays } from "../../lib/nostr/relayInfo";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
@@ -81,6 +81,44 @@ function UserRow({ user }: { user: ParsedUser }) {
|
||||
);
|
||||
}
|
||||
|
||||
interface Suggestion {
|
||||
pubkey: string;
|
||||
mutualCount: number;
|
||||
profile: ParsedUser | null;
|
||||
}
|
||||
|
||||
function SuggestionFollowButton({ pubkey }: { pubkey: string }) {
|
||||
const { loggedIn, follows, follow, unfollow } = useUserStore();
|
||||
const isFollowing = follows.includes(pubkey);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
if (!loggedIn) return null;
|
||||
|
||||
const handleClick = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
if (isFollowing) await unfollow(pubkey);
|
||||
else await follow(pubkey);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={pending}
|
||||
className={`text-[11px] px-3 py-1 border transition-colors shrink-0 disabled:opacity-40 disabled:cursor-not-allowed ${
|
||||
isFollowing
|
||||
? "border-border text-text-muted hover:text-danger hover:border-danger/40"
|
||||
: "border-accent/60 text-accent hover:bg-accent hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{pending ? "..." : isFollowing ? "unfollow" : "follow"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchView() {
|
||||
const { pendingSearch } = useUIStore();
|
||||
const [query, setQuery] = useState(pendingSearch ?? "");
|
||||
@@ -91,6 +129,9 @@ export function SearchView() {
|
||||
const [activeTab, setActiveTab] = useState<"notes" | "people">("notes");
|
||||
const [nip50Relays, setNip50Relays] = useState<string[] | null>(null); // null = not checked yet
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
|
||||
const [suggestionsLoading, setSuggestionsLoading] = useState(false);
|
||||
const [suggestionsLoaded, setSuggestionsLoaded] = useState(false);
|
||||
|
||||
const isHashtag = query.trim().startsWith("#");
|
||||
|
||||
@@ -100,6 +141,40 @@ export function SearchView() {
|
||||
getNip50Relays(urls).then(setNip50Relays);
|
||||
}, []);
|
||||
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
|
||||
// Load follow suggestions on mount (only for logged-in users with follows)
|
||||
useEffect(() => {
|
||||
if (!loggedIn || follows.length === 0 || suggestionsLoaded) return;
|
||||
setSuggestionsLoading(true);
|
||||
fetchFollowSuggestions(follows).then(async (results) => {
|
||||
// Load profiles for top suggestions
|
||||
const withProfiles: Suggestion[] = await Promise.all(
|
||||
results.slice(0, 20).map(async (s) => {
|
||||
try {
|
||||
const p = await fetchProfile(s.pubkey);
|
||||
return {
|
||||
...s,
|
||||
profile: p ? {
|
||||
pubkey: s.pubkey,
|
||||
name: (p as Record<string, string>).name || "",
|
||||
displayName: (p as Record<string, string>).display_name || (p as Record<string, string>).name || "",
|
||||
picture: (p as Record<string, string>).picture || "",
|
||||
nip05: (p as Record<string, string>).nip05 || "",
|
||||
about: (p as Record<string, string>).about || "",
|
||||
} : null,
|
||||
};
|
||||
} catch {
|
||||
return { ...s, profile: null };
|
||||
}
|
||||
})
|
||||
);
|
||||
setSuggestions(withProfiles.filter((s) => s.profile !== null));
|
||||
setSuggestionsLoading(false);
|
||||
setSuggestionsLoaded(true);
|
||||
}).catch(() => setSuggestionsLoading(false));
|
||||
}, [loggedIn, follows.length]);
|
||||
|
||||
// Run pending search from hashtag/mention click
|
||||
useEffect(() => {
|
||||
if (pendingSearch) {
|
||||
@@ -212,6 +287,51 @@ export function SearchView() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discover — follow suggestions */}
|
||||
{!searched && !loading && loggedIn && (
|
||||
<div className="border-t border-border">
|
||||
<div className="px-4 py-2.5 border-b border-border">
|
||||
<h3 className="text-text text-[12px] font-medium">Discover people</h3>
|
||||
<p className="text-text-dim text-[10px] mt-0.5">Based on who your follows follow</p>
|
||||
</div>
|
||||
{suggestionsLoading && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">Finding suggestions...</div>
|
||||
)}
|
||||
{suggestions.map((s) => s.profile && (
|
||||
<div key={s.pubkey} className="flex items-center gap-3 px-4 py-2.5 border-b border-border hover:bg-bg-hover transition-colors">
|
||||
<div className="shrink-0 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
{s.profile.picture ? (
|
||||
<img src={s.profile.picture} alt="" className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{(s.profile.displayName || s.profile.name || "?").charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 cursor-pointer" onClick={() => useUIStore.getState().openProfile(s.pubkey)}>
|
||||
<div className="text-text text-[13px] font-medium truncate">
|
||||
{s.profile.displayName || s.profile.name || shortenPubkey(s.pubkey)}
|
||||
</div>
|
||||
<div className="text-text-dim text-[10px]">
|
||||
{s.mutualCount} mutual follow{s.mutualCount !== 1 ? "s" : ""}
|
||||
{s.profile.nip05 && <span className="ml-2">{s.profile.nip05}</span>}
|
||||
</div>
|
||||
{s.profile.about && (
|
||||
<div className="text-text-dim text-[11px] truncate mt-0.5">{s.profile.about}</div>
|
||||
)}
|
||||
</div>
|
||||
<SuggestionFollowButton pubkey={s.pubkey} />
|
||||
</div>
|
||||
))}
|
||||
{suggestionsLoaded && suggestions.length === 0 && (
|
||||
<div className="px-4 py-6 text-text-dim text-[11px] text-center">
|
||||
Follow more people to see suggestions here.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zero results for full-text search */}
|
||||
{searched && totalResults === 0 && !isHashtag && (
|
||||
<div className="px-4 py-8 text-center space-y-3">
|
||||
|
||||
75
src/components/shared/ImageLightbox.tsx
Normal file
75
src/components/shared/ImageLightbox.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: string[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ImageLightbox({ images, index, onClose, onNavigate }: ImageLightboxProps) {
|
||||
const hasPrev = index > 0;
|
||||
const hasNext = index < images.length - 1;
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
if (e.key === "ArrowLeft" && hasPrev) onNavigate(index - 1);
|
||||
if (e.key === "ArrowRight" && hasNext) onNavigate(index + 1);
|
||||
}, [onClose, onNavigate, index, hasPrev, hasNext]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white text-[20px] w-8 h-8 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
{/* Counter */}
|
||||
{images.length > 1 && (
|
||||
<div className="absolute top-4 left-4 text-white/50 text-[12px] z-10">
|
||||
{index + 1} / {images.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Prev arrow */}
|
||||
{hasPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index - 1); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={images[index]}
|
||||
alt=""
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{/* Next arrow */}
|
||||
{hasNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onNavigate(index + 1); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/50 hover:text-white text-[28px] w-10 h-10 flex items-center justify-center transition-colors z-10"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/components/shared/Skeleton.tsx
Normal file
46
src/components/shared/Skeleton.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
export function SkeletonNote() {
|
||||
return (
|
||||
<div className="px-4 py-3 border-b border-border animate-pulse">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-3 w-24 bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-12 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
<div className="h-3 w-full bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-3/4 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonNoteList({ count = 5 }: { count?: number }) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<SkeletonNote key={i} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkeletonProfile() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-32 bg-bg-raised" />
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 rounded-sm bg-bg-raised border-2 border-bg -mt-10" />
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="h-4 w-32 bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-20 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-3 w-full bg-bg-raised rounded-sm" />
|
||||
<div className="h-3 w-2/3 bg-bg-raised rounded-sm" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import pkg from "../../../package.json";
|
||||
const NAV_ITEMS = [
|
||||
{ id: "feed" as const, label: "feed", icon: "◈" },
|
||||
{ id: "search" as const, label: "search", icon: "⌕" },
|
||||
{ id: "bookmarks" as const, label: "bookmarks", icon: "▪" },
|
||||
{ id: "dm" as const, label: "messages", icon: "✉" },
|
||||
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
|
||||
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
||||
|
||||
Reference in New Issue
Block a user