summaryrefslogtreecommitdiff
path: root/front/src/pages
diff options
context:
space:
mode:
Diffstat (limited to 'front/src/pages')
-rw-r--r--front/src/pages/Feed.tsx84
-rw-r--r--front/src/pages/Settings.tsx257
-rw-r--r--front/src/pages/User.tsx138
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;