diff options
Diffstat (limited to 'front/src/pages')
-rw-r--r-- | front/src/pages/Feed.tsx | 84 | ||||
-rw-r--r-- | front/src/pages/Settings.tsx | 257 | ||||
-rw-r--r-- | front/src/pages/User.tsx | 138 |
3 files changed, 397 insertions, 82 deletions
diff --git a/front/src/pages/Feed.tsx b/front/src/pages/Feed.tsx index 65dee64..5902162 100644 --- a/front/src/pages/Feed.tsx +++ b/front/src/pages/Feed.tsx @@ -8,6 +8,8 @@ import { useParams } from "wouter"; import spinner from "@/assets/triangles.svg"; import { useState } from "react"; import Composer from "@/components/composer/Composer"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; // import UserFeed from "./User"; import { P404 } from "@/Router"; import { isValidPatp } from "urbit-ob"; @@ -88,11 +90,89 @@ function Global() { return <p>Error</p>; } function Nostr() { - const { nostrFeed } = useLocalState(); + const { nostrFeed, api } = useLocalState((s) => ({ + nostrFeed: s.nostrFeed, + api: s.api, + })); + const [isSyncing, setIsSyncing] = useState(false); const feed = eventsToFc(nostrFeed); console.log({ feed }); const refetch = () => feed; - return <PostList data={feed} refetch={refetch} />; + + const handleResync = async () => { + if (!api) return; + + setIsSyncing(true); + try { + await api.syncRelays(); + toast.success("Nostr feed sync initiated"); + } catch (error) { + toast.error("Failed to sync Nostr feed"); + console.error("Sync error:", error); + } finally { + setIsSyncing(false); + } + }; + + // Show empty state with resync option when no feed data + if (!feed || !feed.feed || Object.keys(feed.feed).length === 0) { + return ( + <div className="nostr-empty-state"> + <div className="empty-content"> + <Icon name="nostr" size={48} color="textMuted" /> + <h3>No Nostr Posts</h3> + <p> + Your Nostr feed appears to be empty. Try syncing with your relays to + fetch the latest posts. + </p> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn" + > + {isSyncing ? ( + <> + <img src={spinner} alt="Loading" className="btn-spinner" /> + Syncing... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Sync Relays + </> + )} + </button> + </div> + </div> + ); + } + + // Show feed with resync button in header + return ( + <div className="nostr-feed"> + <div className="nostr-header"> + <div className="feed-info"> + <h4>Nostr Feed</h4> + <span className="post-count"> + {Object.keys(feed.feed).length} posts + </span> + </div> + <button + onClick={handleResync} + disabled={isSyncing} + className="resync-btn-small" + title="Sync with Nostr relays" + > + {isSyncing ? ( + <img src={spinner} alt="Loading" className="btn-spinner-small" /> + ) : ( + <Icon name="settings" size={16} /> + )} + </button> + </div> + <PostList data={feed} refetch={refetch} /> + </div> + ); } export default Loader; diff --git a/front/src/pages/Settings.tsx b/front/src/pages/Settings.tsx index e0f1da9..6b6f7bd 100644 --- a/front/src/pages/Settings.tsx +++ b/front/src/pages/Settings.tsx @@ -1,89 +1,206 @@ import useLocalState from "@/state/state"; -import type { UserProfile } from "@/types/nostril"; import { useState } from "react"; +import toast from "react-hot-toast"; +import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; +import Icon from "@/components/Icon"; +import "@/styles/Settings.css"; function Settings() { - const { UISettings, keys, profiles, relays, api } = useLocalState(); + const { key, relays, api } = useLocalState((s) => ({ + key: s.key, + relays: s.relays, + api: s.api, + })); const [newRelay, setNewRelay] = useState(""); - async function saveSetting( - bucket: string, - key: string, - value: string | boolean | number | string[], - ) { - const json = { - "put-entry": { - desk: "trill", - "bucket-key": bucket, - "entry-key": key, - value, - }, - }; - // const res = await poke("settings", "settings-event", json); - // if (res) refetchSettings(); - } + const [isAddingRelay, setIsAddingRelay] = useState(false); + const [isCyclingKey, setIsCyclingKey] = useState(false); + async function removeRelay(url: string) { - console.log({ url }); + try { + await api?.deleteRelay(url); + toast.success("Relay removed"); + } catch (error) { + toast.error("Failed to remove relay"); + console.error("Remove relay error:", error); + } } + async function addNewRelay() { - // - // await addnr(newRelay); - } - async function removeProfile(pubkey: string) { - api!.removeKey(pubkey); + if (!newRelay.trim()) { + toast.error("Please enter a relay URL"); + return; + } + + setIsAddingRelay(true); + try { + const valid = ["wss:", "ws:"]; + const url = new URL(newRelay); + if (!valid.includes(url.protocol)) { + toast.error("Invalid Relay URL - must use wss:// or ws://"); + return; + } + + await api?.addRelay(newRelay); + toast.success("Relay added"); + setNewRelay(""); + } catch (error) { + toast.error("Invalid relay URL or failed to add relay"); + console.error("Add relay error:", error); + } finally { + setIsAddingRelay(false); + } } - async function createProfile() { - // - api!.createKey(); + + async function cycleKey() { + setIsCyclingKey(true); + try { + await api?.cycleKeys(); + toast.success("Key cycled successfully"); + } catch (error) { + toast.error("Failed to cycle key"); + console.error("Cycle key error:", error); + } finally { + setIsCyclingKey(false); + } } + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === "Enter") { + addNewRelay(); + } + }; + return ( - <div id="settings"> - <h1>Settings</h1> - <div className="setting"> - <label>Pubkeys</label> - {keys.map((k) => { - const profile = profiles.get(k); - const profileDiv = !profile ? ( - <div className="profile"> - <div>Pubkey: {k}</div> - <p>No profile set</p>) - </div> - ) : ( - <div className="profile"> - {profile.picture && <img src={profile.picture} />} - <div>Name: {profile.name}</div> - <div>Pubkey: {k}</div> - <div>About: {profile.about}</div> - <button onClick={() => removeProfile(k)}>x</button> + <div className="settings-page"> + <div className="settings-header"> + <h1>Settings</h1> + <p>Manage your Nostrill configuration and preferences</p> + </div> + + <div className="settings-content"> + {/* Appearance Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="settings" size={20} /> + <h2>Appearance</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Theme</label> + <p>Choose your preferred color theme</p> + </div> + <div className="setting-control"> + <ThemeSwitcher /> + </div> </div> - ); - return ( - <div className="options flex" key={k}> - {profileDiv} + </div> + </div> + + {/* Identity Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="key" size={20} /> + <h2>Identity</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Nostr Public Key</label> + <p>Your unique identifier on the Nostr network</p> + </div> + <div className="setting-control"> + <div className="key-display"> + <code className="pubkey">{key || "No key generated"}</code> + <button + onClick={cycleKey} + disabled={isCyclingKey} + className="cycle-btn" + title="Generate new key pair" + > + {isCyclingKey ? ( + <Icon name="settings" size={16} /> + ) : ( + <Icon name="settings" size={16} /> + )} + {isCyclingKey ? "Cycling..." : "Cycle Key"} + </button> + </div> + </div> </div> - ); - })} - <div className="options flex"> - <button onClick={createProfile}>Create New</button> + </div> </div> - </div> - <div className="setting"> - <label>Nostr Relays</label> - {Object.keys(relays).map((r) => ( - // TODO: add connect button to connect and disc to relay one by one - <div className="options flex" key={r}> - <div>{r}</div> - <button onClick={() => removeRelay(r)}>x</button> + + {/* Nostr Relays Section */} + <div className="settings-section"> + <div className="section-header"> + <Icon name="nostr" size={20} /> + <h2>Nostr Relays</h2> + </div> + <div className="section-content"> + <div className="setting-item"> + <div className="setting-info"> + <label>Connected Relays</label> + <p>Manage your Nostr relay connections</p> + </div> + <div className="setting-control"> + <div className="relay-list"> + {Object.keys(relays).length === 0 ? ( + <div className="no-relays"> + <Icon name="nostr" size={24} color="textMuted" /> + <p>No relays configured</p> + </div> + ) : ( + Object.keys(relays).map((url) => ( + <div key={url} className="relay-item"> + <div className="relay-info"> + <span className="relay-url">{url}</span> + <span className="relay-status">Connected</span> + </div> + <button + onClick={() => removeRelay(url)} + className="remove-relay-btn" + title="Remove relay" + > + × + </button> + </div> + )) + )} + + <div className="add-relay-form"> + <div className="relay-input-group"> + <input + type="text" + value={newRelay} + onChange={(e) => setNewRelay(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="wss://relay.example.com" + className="relay-input" + /> + <button + onClick={addNewRelay} + disabled={isAddingRelay || !newRelay.trim()} + className="add-relay-btn" + > + {isAddingRelay ? ( + <> + <Icon name="settings" size={16} /> + Adding... + </> + ) : ( + <> + <Icon name="settings" size={16} /> + Add Relay + </> + )} + </button> + </div> + </div> + </div> + </div> + </div> </div> - ))} - <div className="options flex"> - <label>Add new</label> - <input - type="text" - value={newRelay} - onChange={(e) => setNewRelay(e.target.value)} - /> - <button onClick={addNewRelay}>Add</button> </div> </div> </div> diff --git a/front/src/pages/User.tsx b/front/src/pages/User.tsx index a1e26f1..e209bb3 100644 --- a/front/src/pages/User.tsx +++ b/front/src/pages/User.tsx @@ -1,20 +1,138 @@ // import spinner from "@/assets/icons/spinner.svg"; import Composer from "@/components/composer/Composer"; import PostList from "@/components/feed/PostList"; -import useLocalState from "@/state/state"; +import ProfileEditor from "@/components/ProfileEditor"; +import useLocalState, { useStore } from "@/state/state"; import type { Ship } from "@/types/urbit"; +import "@/styles/ProfileEditor.css"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import { useState } from "react"; +import type { FC } from "@/types/trill"; function UserFeed({ p }: { p: Ship }) { - const { api, following } = useLocalState(); - const feed = following.get(api!.airlock.our!); + const { api } = useLocalState((s) => ({ + api: s.api, + })); + // auto updating on SSE doesn't work if we do shallow + const { following } = useStore(); + const feed = following.get(p); const refetch = () => feed; - if (p === api!.airlock.our) - return ( - <div id="feed-proper"> - <Composer /> - <PostList data={feed!} refetch={refetch} /> - </div> - ); + const isOwnProfile = p === api?.airlock.our; + const isFollowing = following.has(p); + + const [isFollowLoading, setIsFollowLoading] = useState(false); + const [isAccessLoading, setIsAccessLoading] = useState(false); + const [fc, setFC] = useState<FC>(); + + const handleFollow = async () => { + if (!api) return; + + setIsFollowLoading(true); + try { + if (isFollowing) { + await api.unfollow(p); + toast.success(`Unfollowed ${p}`); + } else { + await api.follow(p); + toast.success(`Now following ${p}`); + } + } catch (error) { + toast.error(`Failed to ${isFollowing ? "unfollow" : "follow"} ${p}`); + console.error("Follow error:", error); + } finally { + setIsFollowLoading(false); + } + }; + + const handleRequestAccess = async () => { + if (!api) return; + + setIsAccessLoading(true); + try { + const res = await api.peekFeed(p); + toast.success(`Access request sent to ${p}`); + if ("error" in res) toast.error(res.error); + else setFC(res.ok); + } catch (error) { + toast.error(`Failed to request access from ${p}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } + }; + + return ( + <div id="user-page"> + <ProfileEditor ship={p} /> + + {!isOwnProfile && ( + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${isFollowing ? "following" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {isFollowing ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={isFollowing ? "bell" : "pals"} size={16} /> + {isFollowing ? "Unfollow" : "Follow"} + </> + )} + </button> + + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Requesting... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Request Access + </> + )} + </button> + </div> + )} + + {feed ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ) : fc ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={fc} refetch={refetch} /> + </div> + ) : null} + + {!isOwnProfile && !feed && !fc && ( + <div id="other-user-feed"> + <div className="empty-feed-message"> + <Icon name="messages" size={48} color="textMuted" /> + <h3>No Posts Available</h3> + <p> + This user's posts are not publicly visible. + {!isFollowing && " Try following them"} or request temporary + access to see their content. + </p> + </div> + </div> + )} + </div> + ); } export default UserFeed; |