diff options
| -rw-r--r-- | gui/src/App.tsx | 4 | ||||
| -rw-r--r-- | gui/src/Router.tsx | 27 | ||||
| -rw-r--r-- | gui/src/components/layout/Sidebar.tsx | 20 | ||||
| -rw-r--r-- | gui/src/components/modals/UserModal.tsx | 98 | ||||
| -rw-r--r-- | gui/src/components/nostr/Feed.tsx | 1 | ||||
| -rw-r--r-- | gui/src/components/nostr/User.tsx | 128 | ||||
| -rw-r--r-- | gui/src/components/post/Header.tsx | 4 | ||||
| -rw-r--r-- | gui/src/components/profile/Profile.tsx | 52 | ||||
| -rw-r--r-- | gui/src/components/trill/User.tsx | 180 | ||||
| -rw-r--r-- | gui/src/logic/nostr.ts | 14 | ||||
| -rw-r--r-- | gui/src/pages/Error.tsx | 60 | ||||
| -rw-r--r-- | gui/src/pages/Feed.tsx | 15 | ||||
| -rw-r--r-- | gui/src/pages/Thread.tsx | 2 | ||||
| -rw-r--r-- | gui/src/pages/User.tsx | 219 | ||||
| -rw-r--r-- | gui/src/state/state.ts | 7 | ||||
| -rw-r--r-- | gui/src/styles/ErrorPage.css | 156 | ||||
| -rw-r--r-- | gui/src/styles/Profile.css | 17 | ||||
| -rw-r--r-- | gui/src/styles/UserModal.css | 46 | ||||
| -rw-r--r-- | gui/src/styles/styles.css | 1 |
19 files changed, 776 insertions, 275 deletions
diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 415cb66..28398e4 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -14,9 +14,8 @@ const queryClient = new QueryClient(); function App() { const [loading, setLoading] = useState(true); console.log("NOSTRILL INIT"); - const { init, modal } = useLocalState((s) => ({ + const { init } = useLocalState((s) => ({ init: s.init, - modal: s.modal, })); useEffect(() => { init().then((_res: any) => { @@ -36,7 +35,6 @@ function App() { <QueryClientProvider client={queryClient}> {/* {isMobile ? <MobileUI /> : <DesktopUI />} */} <Router /> - {modal && modal} <Toaster position="top-center" /> </QueryClientProvider> </ThemeProvider> diff --git a/gui/src/Router.tsx b/gui/src/Router.tsx index ee3aa0d..5026ef0 100644 --- a/gui/src/Router.tsx +++ b/gui/src/Router.tsx @@ -1,12 +1,14 @@ import Sidebar from "@/components/layout/Sidebar"; - -// new +import useLocalState from "@/state/state"; import Feed from "@/pages/Feed"; +import User from "@/pages/User"; import Settings from "@/pages/Settings"; import Thread from "@/pages/Thread"; import { Switch, Router, Redirect, Route } from "wouter"; +import { P404 } from "./pages/Error"; export default function r() { + const modal = useLocalState((s) => s.modal); return ( <Switch> <Router base="/apps/nostrill"> @@ -14,26 +16,17 @@ export default function r() { <main> <Route path="/" component={toGlobal} /> <Route path="/sets" component={Settings} /> - <Route path="/feed/:taip" component={Feed} /> - <Route path="/feed/:host/:id" component={Thread} /> + <Route path="/f" component={Feed} /> + <Route path="/f/:taip" component={Feed} /> + <Route path="/u/:user" component={User} /> + <Route path="/t/:host/:id" component={Thread} /> </main> + {modal && modal} </Router> <Route component={P404} /> </Switch> ); } function toGlobal() { - return <Redirect to="/feed/nostr" />; -} - -export function P404() { - return <h1 className="x-center">404</h1>; -} -export function ErrorPage({ msg }: { msg: string }) { - return ( - <div> - <P404 /> - <h3>{msg}</h3> - </div> - ); + return <Redirect to="/f" />; } diff --git a/gui/src/components/layout/Sidebar.tsx b/gui/src/components/layout/Sidebar.tsx index c267e2f..bc11e48 100644 --- a/gui/src/components/layout/Sidebar.tsx +++ b/gui/src/components/layout/Sidebar.tsx @@ -7,16 +7,16 @@ import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; function SlidingMenu() { const [_, navigate] = useLocation(); - const { api, unreadNotifications, setModal } = useLocalState((s) => ({ + const { api, unreadNotifications, setModal } = useLocalState((s) => ({ api: s.api, unreadNotifications: s.unreadNotifications, - setModal: s.setModal + setModal: s.setModal, })); - + function goto(to: string) { navigate(to); } - + function openNotifications() { // We'll create this component next import("../NotificationCenter").then(({ default: NotificationCenter }) => { @@ -30,11 +30,15 @@ function SlidingMenu() { <h3> Nostrill </h3> </div> <h3>Feeds</h3> - <div className="opt" role="link" onClick={() => goto(`/feed/global`)}> + <div className="opt" role="link" onClick={() => goto(`/f/global`)}> <Icon name="home" size={20} /> <div>Home</div> </div> - <div className="opt notification-item" role="link" onClick={openNotifications}> + <div + className="opt notification-item" + role="link" + onClick={openNotifications} + > <div className="notification-icon-wrapper"> <Icon name="bell" size={20} /> {unreadNotifications > 0 && ( @@ -50,7 +54,7 @@ function SlidingMenu() { <div className="opt tbd" role="link" - // onClick={() => goto("/chat")} + // onClick={() => setModal(<p>lmao</p>)} > <Icon name="messages" size={20} /> <div>Messages</div> @@ -63,7 +67,7 @@ function SlidingMenu() { <div className="opt" role="link" - onClick={() => goto(`/feed/${api!.airlock.our}`)} + onClick={() => goto(`/u/${api!.airlock.our}`)} > <Icon name="profile" size={20} /> <div>Profile</div> diff --git a/gui/src/components/modals/UserModal.tsx b/gui/src/components/modals/UserModal.tsx index 0694f1e..aeffc95 100644 --- a/gui/src/components/modals/UserModal.tsx +++ b/gui/src/components/modals/UserModal.tsx @@ -1,3 +1,5 @@ +import "@/styles/Profile.css"; +import "@/styles/UserModal.css"; import Modal from "./Modal"; import Avatar from "../Avatar"; import Icon from "@/components/Icon"; @@ -10,14 +12,15 @@ import { generateNprofile } from "@/logic/nostr"; import { useState } from "react"; export default function ({ userString }: { userString: string }) { - const { setModal, api, pubkey, profiles, following, followers } = useLocalState((s) => ({ - setModal: s.setModal, - api: s.api, - pubkey: s.pubkey, - profiles: s.profiles, - following: s.following, - followers: s.followers, - })); + const { setModal, api, pubkey, profiles, following, followers } = + useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + pubkey: s.pubkey, + profiles: s.profiles, + following: s.following, + followers: s.followers, + })); const [_, navigate] = useLocation(); const [loading, setLoading] = useState(false); @@ -64,25 +67,28 @@ export default function ({ userString }: { userString: string }) { } async function handleFollow(e: React.MouseEvent) { + if ("error" in user) return; e.stopPropagation(); if (!api) return; setLoading(true); try { if (isFollowing) { - const result = await api.unfollow(userString); - if ("ok" in result) { - toast.success(`Unfollowed ${profile?.name || userString}`); - } else { - toast.error(result.error); - } + const result = await api.unfollow(user); + console.log(result); + // if ("ok" in result) { + // toast.success(`Unfollowed ${profile?.name || userString}`); + // } else { + // toast.error(result.error); + // } } else { - const result = await api.follow(userString); - if ("ok" in result) { - toast.success(`Following ${profile?.name || userString}`); - } else { - toast.error(result.error); - } + const result = await api.follow(user); + console.log(result); + // if ("ok" in result) { + // toast.success(`Following ${profile?.name || userString}`); + // } else { + // toast.error(result.error); + // } } } catch (err) { toast.error("Action failed"); @@ -101,9 +107,10 @@ export default function ({ userString }: { userString: string }) { } const displayName = profile?.name || ("urbit" in user ? user.urbit : "Anon"); - const truncatedId = userString.length > 20 - ? `${userString.slice(0, 10)}...${userString.slice(-8)}` - : userString; + const truncatedId = + userString.length > 20 + ? `${userString.slice(0, 10)}...${userString.slice(-8)}` + : userString; // Check if a string is a URL const isURL = (str: string): boolean => { @@ -111,7 +118,7 @@ export default function ({ userString }: { userString: string }) { new URL(str); return true; } catch { - return str.startsWith('http://') || str.startsWith('https://'); + return str.startsWith("http://") || str.startsWith("https://"); } }; @@ -121,7 +128,7 @@ export default function ({ userString }: { userString: string }) { // Filter out banner from other fields since we display it separately const otherFields = profile?.other ? Object.entries(profile.other).filter( - ([key]) => key.toLowerCase() !== 'banner' + ([key]) => key.toLowerCase() !== "banner", ) : []; @@ -130,7 +137,7 @@ export default function ({ userString }: { userString: string }) { <div className="user-modal"> {/* Banner Image */} {bannerImage && ( - <div className="user-modal-banner"> + <div className="user-banner"> <img src={bannerImage} alt="Profile banner" /> </div> )} @@ -174,7 +181,9 @@ export default function ({ userString }: { userString: string }) { <span className="badge badge-nostr">Nostr</span> )} {itsMe && <span className="badge badge-me">You</span>} - {isFollower && !itsMe && <span className="badge badge-follows">Follows you</span>} + {isFollower && !itsMe && ( + <span className="badge badge-follows">Follows you</span> + )} </div> </div> </div> @@ -213,7 +222,11 @@ export default function ({ userString }: { userString: string }) { onClick={(e) => e.stopPropagation()} > {value} - <Icon name="nostr" size={12} className="external-link-icon" /> + <Icon + name="nostr" + size={12} + className="external-link-icon" + /> </a> ) : ( <span className="field-value">{value}</span> @@ -235,21 +248,20 @@ export default function ({ userString }: { userString: string }) { {loading ? "..." : isFollowing ? "Following" : "Follow"} </button> )} + <> + <button + className="action-btn secondary" + onClick={() => { + navigate(`/u/${userString}`); + close(); + }} + > + <Icon name="home" size={16} /> + View Feed + </button> + </> - {"urbit" in user ? ( - <> - <button - className="action-btn secondary" - onClick={() => { - navigate(`/feed/${userString}`); - close(); - }} - > - <Icon name="home" size={16} /> - View Feed - </button> - </> - ) : ( + {"nostr" in user ? ( <button className="action-btn secondary" onClick={handleAvatarClick} @@ -257,7 +269,7 @@ export default function ({ userString }: { userString: string }) { <Icon name="nostr" size={16} /> View on Primal </button> - )} + ) : null} </div> </div> </Modal> diff --git a/gui/src/components/nostr/Feed.tsx b/gui/src/components/nostr/Feed.tsx index 0e74cea..d21307b 100644 --- a/gui/src/components/nostr/Feed.tsx +++ b/gui/src/components/nostr/Feed.tsx @@ -14,6 +14,7 @@ export default function Nostr() { })); console.log({ relays }); const [isSyncing, setIsSyncing] = useState(false); + console.log({ nostrFeed }); const feed = eventsToFc(nostrFeed); console.log({ feed }); const refetch = () => feed; diff --git a/gui/src/components/nostr/User.tsx b/gui/src/components/nostr/User.tsx new file mode 100644 index 0000000..a9e9e2f --- /dev/null +++ b/gui/src/components/nostr/User.tsx @@ -0,0 +1,128 @@ +import useLocalState from "@/state/state"; +import { useState } from "react"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import type { UserType } from "@/types/nostrill"; +import type { FC } from "@/types/trill"; +import Composer from "../composer/Composer"; +import PostList from "@/components/feed/PostList"; + +export default function NostrUser({ + user, + userString, + feed, + isFollowLoading, + setIsFollowLoading, + isAccessLoading, + setIsAccessLoading, +}: { + user: UserType; + userString: string; + feed: FC | undefined; + isFollowLoading: boolean; + setIsFollowLoading: (b: boolean) => void; + isAccessLoading: boolean; + setIsAccessLoading: (b: boolean) => void; +}) { + const { api } = useLocalState((s) => ({ + api: s.api, + })); + const [fc, setFC] = useState<FC>(); + + // Show empty state with resync option when no feed data + + async function refetch() { + // + } + async function handleFollow() { + if (!api) return; + + setIsFollowLoading(true); + try { + if (feed) { + await api.unfollow(user); + } else { + await api.follow(user); + toast.success(`Follow request sent to ${userString}`); + } + } catch (error) { + toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`); + setIsFollowLoading(false); + console.error("Follow error:", error); + } + } + async function handleRequestAccess() { + if (!api) return; + + setIsAccessLoading(true); + // try { + // const res = await api.peekFeed(user.urbit); + // toast.success(`Access request sent to ${user.urbit}`); + // addNotification({ + // type: "access_request", + // from: userString, + // message: `Access request sent to ${userString}`, + // }); + // if ("error" in res) toast.error(res.error); + // else { + // console.log("peeked", res.ok.feed); + // setFC(res.ok.feed); + // if (res.ok.profile) addProfile(userString, res.ok.profile); + // } + // } catch (error) { + // toast.error(`Failed to request access from ${user.urbit}`); + // console.error("Access request error:", error); + // } finally { + // setIsAccessLoading(false); + // } + } + return ( + <> + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${!!feed ? "" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {!!feed ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={!!feed ? "bell" : "pals"} size={16} /> + {!!feed ? "Unfollow" : "Follow"} + </> + )} + </button> + + {(!feed || !feed.feed || Object.keys(feed.feed).length === 0) && ( + <button + onClick={handleRequestAccess} + disabled={isAccessLoading} + className="action-btn access" + > + {isAccessLoading ? ( + <> + <Icon name="settings" size={16} /> + Fetching... + </> + ) : ( + <> + <Icon name="key" size={16} /> + Fetch Feed + </> + )} + </button> + )} + </div> + {(feed || fc) && ( + <div id="feed-proper"> + <Composer /> + <PostList data={(feed || fc)!} refetch={refetch} /> + </div> + )} + </> + ); +} diff --git a/gui/src/components/post/Header.tsx b/gui/src/components/post/Header.tsx index b0822b4..5898eba 100644 --- a/gui/src/components/post/Header.tsx +++ b/gui/src/components/post/Header.tsx @@ -8,12 +8,12 @@ function Header(props: PostProps) { // console.log(props.poast.author.length, "length"); function go(e: React.MouseEvent) { e.stopPropagation(); - navigate(`/feed/${poast.host}`); + navigate(`/u/${poast.host}`); } function openThread(e: React.MouseEvent) { e.stopPropagation(); const sel = window.getSelection()?.toString(); - if (!sel) navigate(`/feed/${poast.host}/${poast.id}`); + if (!sel) navigate(`/t/${poast.host}/${poast.id}`); } const { poast } = props; const name = profile ? ( diff --git a/gui/src/components/profile/Profile.tsx b/gui/src/components/profile/Profile.tsx index b5f22e9..ab65a7b 100644 --- a/gui/src/components/profile/Profile.tsx +++ b/gui/src/components/profile/Profile.tsx @@ -16,6 +16,7 @@ const Loader: React.FC<Props> = (props) => { profiles: s.profiles, })); const profile = profiles.get(props.userString); + console.log({ profiles }); if (props.isMe) return <ProfileEditor {...props} profile={profile} />; else return <Profile profile={profile} {...props} />; @@ -32,31 +33,50 @@ function Profile({ // Initialize state with existing profile or defaults // View-only mode for other users' profiles - no editing allowed + const bannerImage = profile?.other?.banner || profile?.other?.Banner; const customFields = profile?.other ? Object.entries(profile.other) : []; return ( - <div className="profile view-mode"> - <div className="profile-picture"> - <Avatar - user={user} - userString={userString} - size={120} - picOnly={true} - profile={profile} - /> + <div className="profile"> + {bannerImage && ( + <div className="user-banner"> + <img src={bannerImage} alt="Profile banner" /> + </div> + )} + <div className="flex items-center gap-4"> + <div className="profile-picture"> + <Avatar + user={user} + userString={userString} + size={120} + picOnly={true} + profile={profile} + /> + </div> + <h2 className="text-4xl">{profile?.name || userString}</h2> </div> <div className="profile-info"> - <h2>{profile?.name || userString}</h2> {profile?.about && <p className="profile-about">{profile.about}</p>} {customFields.length > 0 && ( <div className="profile-custom-fields"> <h4>Additional Info</h4> - {customFields.map(([key, value], index) => ( - <div key={index} className="custom-field-view"> - <span className="field-key">{key}:</span> - <span className="field-value">{value}</span> - </div> - ))} + + {customFields.map(([key, value], index) => { + if (key.toLocaleLowerCase() === "banner") return null; + const isURL = URL.parse(value); + return ( + <div key={index} className="custom-field-view"> + <span className="field-key">{key}:</span> + {isURL ? ( + <a className="field-value" href={value} target="_blank"> + {value} + </a> + ) : ( + <span className="field-value">{value}</span> + )} + </div> + ); + })} </div> )} </div> diff --git a/gui/src/components/trill/User.tsx b/gui/src/components/trill/User.tsx new file mode 100644 index 0000000..b7b53d6 --- /dev/null +++ b/gui/src/components/trill/User.tsx @@ -0,0 +1,180 @@ +// 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 Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import { useEffect, useState } from "react"; +import type { FC } from "@/types/trill"; +import type { UserType } from "@/types/nostrill"; + +function UserFeed({ + user, + userString, + feed, + isFollowLoading, + setIsFollowLoading, + isAccessLoading, + setIsAccessLoading, +}: { + user: UserType; + userString: string; + feed: FC | undefined; + isFollowLoading: boolean; + setIsFollowLoading: (b: boolean) => void; + isAccessLoading: boolean; + setIsAccessLoading: (b: boolean) => void; +}) { + const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({ + api: s.api, + addProfile: s.addProfile, + addNotification: s.addNotification, + lastFact: s.lastFact, + })); + const hasFeed = !feed ? false : Object.entries(feed).length > 0; + const refetch = () => feed; + + const [fc, setFC] = useState<FC>(); + + useEffect(() => { + console.log("fact", lastFact); + console.log(isFollowLoading); + if (!isFollowLoading) return; + const follow = lastFact?.fols; + if (!follow) return; + if ("new" in follow) { + if (userString !== follow.new.user) return; + toast.success(`Now following ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "follow", + from: userString, + message: `You are now following ${userString}`, + }); + } else if ("quit" in follow) { + toast.success(`Unfollowed ${userString}`); + setIsFollowLoading(false); + addNotification({ + type: "unfollow", + from: userString, + message: `You unfollowed ${userString}`, + }); + } + }, [lastFact, userString, isFollowLoading]); + + const handleFollow = async () => { + if (!api) return; + + setIsFollowLoading(true); + try { + if (!!feed) { + await api.unfollow(user); + } else { + await api.follow(user); + toast.success(`Follow request sent to ${userString}`); + } + } catch (error) { + toast.error(`Failed to ${!!feed ? "unfollow" : "follow"} ${userString}`); + setIsFollowLoading(false); + console.error("Follow error:", error); + } + }; + + const handleRequestAccess = async () => { + if (!api) return; + if (!("urbit" in user)) return; + + setIsAccessLoading(true); + try { + const res = await api.peekFeed(user.urbit); + toast.success(`Access request sent to ${user.urbit}`); + addNotification({ + type: "access_request", + from: userString, + message: `Access request sent to ${userString}`, + }); + if ("error" in res) toast.error(res.error); + else { + console.log("peeked", res.ok.feed); + setFC(res.ok.feed); + if (res.ok.profile) addProfile(userString, res.ok.profile); + } + } catch (error) { + toast.error(`Failed to request access from ${user.urbit}`); + console.error("Access request error:", error); + } finally { + setIsAccessLoading(false); + } + }; + console.log({ user, userString, feed, fc }); + + return ( + <> + <div className="user-actions"> + <button + onClick={handleFollow} + disabled={isFollowLoading} + className={`action-btn ${!!feed ? "" : "follow"}`} + > + {isFollowLoading ? ( + <> + <Icon name="settings" size={16} /> + {!!feed ? "Unfollowing..." : "Following..."} + </> + ) : ( + <> + <Icon name={!!feed ? "bell" : "pals"} size={16} /> + {!!feed ? "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 && hasFeed ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={feed} refetch={refetch} /> + </div> + ) : fc ? ( + <div id="feed-proper"> + <Composer /> + <PostList data={fc} refetch={refetch} /> + </div> + ) : null} + + {!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. + {!!feed && " Try following them"} or request temporary access to + see their content. + </p> + </div> + </div> + )} + </> + ); +} + +export default UserFeed; diff --git a/gui/src/logic/nostr.ts b/gui/src/logic/nostr.ts index 7da9b91..3a9a586 100644 --- a/gui/src/logic/nostr.ts +++ b/gui/src/logic/nostr.ts @@ -21,6 +21,20 @@ export function generateNprofile(pubkey: string) { const nprofile = nip19.nprofileEncode(prof); return nprofile; } +export function isValidNostrKey(key: string): boolean { + try { + nip19.decode(key); + return true; + } catch (e) { + try { + nip19.npubEncode(key); + return true; + } catch (e2) { + console.error(e2, "not valid nostr key"); + return false; + } + } +} // let sk = generateSecretKey() // let nsec = nip19.nsecEncode(sk) diff --git a/gui/src/pages/Error.tsx b/gui/src/pages/Error.tsx new file mode 100644 index 0000000..c29e6a6 --- /dev/null +++ b/gui/src/pages/Error.tsx @@ -0,0 +1,60 @@ +import "@/styles/ErrorPage.css"; +import Icon from "@/components/Icon"; +import { Link } from "wouter"; +export function P404() { + return ( + <div className="error-page"> + <div className="error-content"> + <div className="error-icon-wrapper"> + <Icon name="crow" size={80} /> + </div> + <h1 className="error-title">404</h1> + <h2 className="error-subtitle">Page Not Found</h2> + <p className="error-message"> + The page you're looking for doesn't exist or has been moved. + </p> + <div className="error-actions"> + <Link href="/apps/nostrill/f/nostr"> + <button className="error-btn primary"> + <Icon name="home" size={18} /> + Go to Feed + </button> + </Link> + <Link href="/apps/nostrill/sets"> + <button className="error-btn secondary"> + <Icon name="settings" size={18} /> + Settings + </button> + </Link> + </div> + </div> + </div> + ); +} + +export function ErrorPage({ msg }: { msg: string }) { + return ( + <div> + <P404 /> + <h3>{msg}</h3> + <div className="error-page"> + <div className="error-content"> + <div className="error-icon-wrapper"> + <Icon name="crow" size={80} /> + </div> + <h1 className="error-title">Oops!</h1> + <h2 className="error-subtitle">Something went wrong</h2> + <p className="error-message">{msg}</p> + <div className="error-actions"> + <Link href="/apps/nostrill/f/nostr"> + <button className="error-btn primary"> + <Icon name="home" size={18} /> + Go to Feed + </button> + </Link> + </div> + </div> + </div> + </div> + ); +} diff --git a/gui/src/pages/Feed.tsx b/gui/src/pages/Feed.tsx index 02f7b1a..bb001d4 100644 --- a/gui/src/pages/Feed.tsx +++ b/gui/src/pages/Feed.tsx @@ -1,35 +1,30 @@ -// import spinner from "@/assets/icons/spinner.svg"; import "@/styles/trill.css"; import "@/styles/feed.css"; -import UserLoader from "./User"; import PostList from "@/components/feed/PostList"; import useLocalState from "@/state/state"; import { useParams } from "wouter"; import spinner from "@/assets/triangles.svg"; import { useState } from "react"; import Composer from "@/components/composer/Composer"; -import { ErrorPage } from "@/Router"; +import { ErrorPage } from "@/pages/Error"; import NostrFeed from "@/components/nostr/Feed"; type FeedType = "global" | "following" | "nostr"; function Loader() { - // const { api } = useLocalState(); const params = useParams(); console.log({ params }); - // const [loc, navigate] = useLocation(); - // console.log({ loc }); - // const our = api!.airlock.ship; + if (!params.taip) return <FeedPage t="nostr" />; if (params.taip === "global") return <FeedPage t={"global"} />; + if (params.taip === "following") return <FeedPage t={"following"} />; if (params.taip === "nostr") return <FeedPage t={"nostr"} />; // else if (param === FeedType.Rumors) return <Rumors />; // else if (param === FeedType.Home) return <UserFeed p={our} />; - else if (params.taip) return <UserLoader userString={params.taip!} />; else return <ErrorPage msg="No such page" />; } function FeedPage({ t }: { t: FeedType }) { const [active, setActive] = useState<FeedType>(t); return ( - <main> + <> <div id="top-tabs"> <div className={active === "global" ? "active" : ""} @@ -60,7 +55,7 @@ function FeedPage({ t }: { t: FeedType }) { <NostrFeed /> ) : null} </div> - </main> + </> ); } // {active === "global" ? ( diff --git a/gui/src/pages/Thread.tsx b/gui/src/pages/Thread.tsx index dec8946..fc215f2 100644 --- a/gui/src/pages/Thread.tsx +++ b/gui/src/pages/Thread.tsx @@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query"; import useLocalState from "@/state/state"; import Icon from "@/components/Icon"; import spinner from "@/assets/triangles.svg"; -import { ErrorPage } from "@/Router"; +import { ErrorPage } from "@/pages/Error"; import "@/styles/trill.css"; import "@/styles/feed.css"; import Post from "@/components/post/Post"; diff --git a/gui/src/pages/User.tsx b/gui/src/pages/User.tsx index b73cd96..1611037 100644 --- a/gui/src/pages/User.tsx +++ b/gui/src/pages/User.tsx @@ -9,204 +9,85 @@ import { useEffect, useState } from "react"; import type { FC } from "@/types/trill"; import type { UserType } from "@/types/nostrill"; import { isValidPatp } from "urbit-ob"; -import { isValidNostrPubkey } from "@/logic/nostrill"; -import { ErrorPage } from "@/Router"; - -function UserLoader({ userString }: { userString: string }) { - const { api, pubkey } = useLocalState((s) => ({ - api: s.api, - pubkey: s.pubkey, - })); - // auto updating on SSE doesn't work if we do shallow - - const user = isValidPatp(userString) - ? { urbit: userString } - : isValidNostrPubkey(userString) - ? { nostr: userString } - : { error: "" }; - - const isOwnProfile = - "urbit" in user - ? user.urbit === api?.airlock.our - : "nostr" in user - ? pubkey === user.nostr - : false; - if ("error" in user) return <ErrorPage msg={"Invalid user"} />; - else - return <UserFeed user={user} userString={userString} isMe={isOwnProfile} />; +import { ErrorPage } from "@/pages/Error"; +import { useParams } from "wouter"; +import { isValidNostrKey } from "@/logic/nostr"; +import TrillFeed from "@/components/trill/User"; +import NostrFeed from "@/components/nostr/User"; + +function UserLoader() { + const params = useParams(); + console.log({ params }); + const userString = params.user; + if (!userString) return <ErrorPage msg="no such user" />; + else if (isValidPatp(userString)) + return <UserFeed user={{ urbit: userString }} userString={userString} />; + else if (isValidNostrKey(userString)) + return <UserFeed user={{ nostr: userString }} userString={userString} />; + else return <ErrorPage msg="no such user" />; } function UserFeed({ user, userString, - isMe, }: { user: UserType; userString: string; - isMe: boolean; }) { - const { api, addProfile, addNotification, lastFact } = useLocalState((s) => ({ + const { api, pubkey } = useLocalState((s) => ({ api: s.api, addProfile: s.addProfile, addNotification: s.addNotification, lastFact: s.lastFact, + pubkey: s.pubkey, })); + const isMe = + "urbit" in user + ? user.urbit === api?.airlock.our + : "nostr" in user + ? pubkey === user.nostr + : false; // auto updating on SSE doesn't work if we do shallow const { following } = useStore(); const feed = following.get(userString); const hasFeed = !feed ? false : Object.entries(feed).length > 0; const refetch = () => feed; - const isFollowing = following.has(userString); const [isFollowLoading, setIsFollowLoading] = useState(false); const [isAccessLoading, setIsAccessLoading] = useState(false); - const [fc, setFC] = useState<FC>(); - - useEffect(() => { - console.log("fact", lastFact); - console.log(isFollowLoading); - if (!isFollowLoading) return; - const follow = lastFact?.fols; - if (!follow) return; - if ("new" in follow) { - if (userString !== follow.new.user) return; - toast.success(`Now following ${userString}`); - setIsFollowLoading(false); - addNotification({ - type: "follow", - from: userString, - message: `You are now following ${userString}`, - }); - } else if ("quit" in follow) { - toast.success(`Unfollowed ${userString}`); - setIsFollowLoading(false); - addNotification({ - type: "unfollow", - from: userString, - message: `You unfollowed ${userString}`, - }); - } - }, [lastFact, userString, isFollowLoading]); - - const handleFollow = async () => { - if (!api) return; - - setIsFollowLoading(true); - try { - if (isFollowing) { - await api.unfollow(user); - } else { - await api.follow(user); - toast.success(`Follow request sent to ${userString}`); - } - } catch (error) { - toast.error( - `Failed to ${isFollowing ? "unfollow" : "follow"} ${userString}`, - ); - setIsFollowLoading(false); - console.error("Follow error:", error); - } - }; - - const handleRequestAccess = async () => { - if (!api) return; - if (!("urbit" in user)) return; - - setIsAccessLoading(true); - try { - const res = await api.peekFeed(user.urbit); - toast.success(`Access request sent to ${user.urbit}`); - addNotification({ - type: "access_request", - from: userString, - message: `Access request sent to ${userString}`, - }); - if ("error" in res) toast.error(res.error); - else { - console.log("peeked", res.ok.feed); - setFC(res.ok.feed); - if (res.ok.profile) addProfile(userString, res.ok.profile); - } - } catch (error) { - toast.error(`Failed to request access from ${user.urbit}`); - console.error("Access request error:", error); - } finally { - setIsAccessLoading(false); - } - }; - console.log({ user, userString, feed, fc }); return ( <div id="user-page"> <Profile user={user} userString={userString} isMe={isMe} /> - - {!isMe && ( - <div className="user-actions"> - <button - onClick={handleFollow} - disabled={isFollowLoading} - className={`action-btn ${isFollowing ? "" : "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 && hasFeed ? ( - <div id="feed-proper"> - <Composer /> - <PostList data={feed} refetch={refetch} /> - </div> - ) : fc ? ( - <div id="feed-proper"> - <Composer /> - <PostList data={fc} refetch={refetch} /> - </div> + {isMe ? ( + <MyFeed /> + ) : "urbit" in user ? ( + <TrillFeed + user={user} + userString={userString} + feed={feed} + isFollowLoading={isFollowLoading} + setIsFollowLoading={setIsFollowLoading} + isAccessLoading={isAccessLoading} + setIsAccessLoading={setIsAccessLoading} + /> + ) : "nostr" in user ? ( + <NostrFeed + user={user} + userString={userString} + feed={feed} + isFollowLoading={isFollowLoading} + setIsFollowLoading={setIsFollowLoading} + isAccessLoading={isAccessLoading} + setIsAccessLoading={setIsAccessLoading} + /> ) : null} - - {!isMe && !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 UserLoader; + +function MyFeed() { + return <></>; +} diff --git a/gui/src/state/state.ts b/gui/src/state/state.ts index 7d433f4..69633e3 100644 --- a/gui/src/state/state.ts +++ b/gui/src/state/state.ts @@ -91,7 +91,12 @@ export const useStore = creator((set, get) => ({ } } if ("nostr" in data.fact) { - set({ nostrFeed: data.fact.nostr }); + if ("feed" in data.fact.nostr) + set({ nostrFeed: data.fact.nostr.feed }); + if ("relays" in data.fact.nostr) + set({ relays: data.fact.nostr.relays }); + // if ("user" in data.fact.nostr) + // if ("thread" in data.fact.nostr) } } }); diff --git a/gui/src/styles/ErrorPage.css b/gui/src/styles/ErrorPage.css new file mode 100644 index 0000000..0d04810 --- /dev/null +++ b/gui/src/styles/ErrorPage.css @@ -0,0 +1,156 @@ +/* Error Page Styles */ + +.error-page { + display: flex; + align-items: center; + justify-content: center; + min-height: 100vh; + width: 100%; + padding: 40px 20px; + background: var(--color-background); +} + +.error-content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 600px; + width: 100%; + padding: 60px 40px; + background: var(--color-surface); + border-radius: 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + animation: fadeInUp 0.5s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.error-icon-wrapper { + margin-bottom: 32px; + opacity: 0.6; + animation: float 3s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} + +.error-title { + margin: 0 0 16px 0; + font-size: 72px; + font-weight: 700; + color: var(--color-primary); + line-height: 1; + letter-spacing: -2px; +} + +.error-subtitle { + margin: 0 0 16px 0; + font-size: 28px; + font-weight: 600; + color: var(--color-text); +} + +.error-message { + margin: 0 0 40px 0; + font-size: 16px; + line-height: 1.6; + color: var(--color-text-secondary); + max-width: 400px; +} + +.error-actions { + display: flex; + gap: 16px; + flex-wrap: wrap; + justify-content: center; +} + +.error-actions a { + text-decoration: none; +} + +.error-btn { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 14px 28px; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.error-btn.primary { + background: var(--color-primary); + color: white; +} + +.error-btn.primary:hover { + background: var(--color-primary-hover); + transform: translateY(-2px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); +} + +.error-btn.secondary { + background: transparent; + color: var(--color-text); + border: 2px solid var(--color-border); +} + +.error-btn.secondary:hover { + background: var(--color-surface-hover); + border-color: var(--color-text-secondary); + transform: translateY(-2px); +} + +.error-btn:active { + transform: translateY(0); +} + +/* Responsive adjustments */ +@media (max-width: 480px) { + .error-content { + padding: 40px 24px; + } + + .error-title { + font-size: 56px; + } + + .error-subtitle { + font-size: 22px; + } + + .error-message { + font-size: 14px; + } + + .error-actions { + flex-direction: column; + width: 100%; + } + + .error-btn { + width: 100%; + justify-content: center; + } +} diff --git a/gui/src/styles/Profile.css b/gui/src/styles/Profile.css index 624cb12..58aefb8 100644 --- a/gui/src/styles/Profile.css +++ b/gui/src/styles/Profile.css @@ -322,4 +322,21 @@ color: var(--color-text-secondary); line-height: 1.5; max-width: 400px; +} + + +/* Banner Image */ +.user-banner { + width: 100%; + height: 160px; + overflow: hidden; + margin: 0; + flex-shrink: 0; +} + +.user-banner img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; }
\ No newline at end of file diff --git a/gui/src/styles/UserModal.css b/gui/src/styles/UserModal.css index bf4ff56..e976b38 100644 --- a/gui/src/styles/UserModal.css +++ b/gui/src/styles/UserModal.css @@ -6,7 +6,8 @@ gap: 20px; min-width: 400px; max-width: 500px; - padding: 24px; + padding: 0; + overflow: hidden; } .user-modal-error { @@ -25,6 +26,17 @@ display: flex; gap: 16px; align-items: flex-start; + padding: 24px 24px 0 24px; + margin-top: -40px; + /* Pull avatar up over banner */ + position: relative; + z-index: 1; +} + +/* Reset margin if no banner */ +.user-modal>.user-modal-header:first-child { + margin-top: 0; + padding: 24px; } .user-modal-avatar-wrapper { @@ -36,8 +48,10 @@ height: 80px; border-radius: 50%; overflow: hidden; - border: 3px solid var(--color-border); + border: 4px solid var(--color-background); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); transition: transform 0.2s ease, border-color 0.2s ease; + background: var(--color-background); } .user-modal-avatar-wrapper .avatar:hover { @@ -135,6 +149,7 @@ /* About Section */ .user-modal-about { padding: 16px; + margin: 0 24px; background: var(--color-surface); border-radius: 8px; border-left: 3px solid var(--color-primary); @@ -153,6 +168,7 @@ display: flex; gap: 24px; padding: 16px; + margin: 0 24px; background: var(--color-surface); border-radius: 8px; } @@ -180,6 +196,7 @@ /* Custom Fields */ .user-modal-custom-fields { padding: 16px; + margin: 0 24px; background: var(--color-surface); border-radius: 8px; } @@ -216,11 +233,32 @@ flex: 1; } +.custom-field-item .field-link { + color: var(--color-primary); + text-decoration: none; + display: inline-flex; + align-items: center; + gap: 4px; + transition: all 0.2s ease; + word-break: break-all; +} + +.custom-field-item .field-link:hover { + color: var(--color-primary-hover); + text-decoration: underline; +} + +.external-link-icon { + flex-shrink: 0; + opacity: 0.7; +} + /* Action Buttons */ .user-modal-actions { display: flex; gap: 12px; - padding-top: 8px; + padding: 16px 24px 24px 24px; + margin-top: 8px; border-top: 1px solid var(--color-border); } @@ -313,4 +351,4 @@ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); max-height: 90vh; overflow-y: auto; -} +}
\ No newline at end of file diff --git a/gui/src/styles/styles.css b/gui/src/styles/styles.css index 5772c40..41b3c4d 100644 --- a/gui/src/styles/styles.css +++ b/gui/src/styles/styles.css @@ -1,5 +1,4 @@ @import "tailwindcss"; -@import "./UserModal.css"; /* assets */ /* fonts */ |
