diff options
Diffstat (limited to 'front')
33 files changed, 1861 insertions, 210 deletions
diff --git a/front/CLAUDE.md b/front/CLAUDE.md new file mode 100644 index 0000000..64ccf9b --- /dev/null +++ b/front/CLAUDE.md @@ -0,0 +1,72 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +- **Development server**: `bun run dev` - Starts Vite dev server on http://localhost:5173 +- **Build**: `bun run build` - TypeScript compilation followed by Vite production build +- **Linting**: `bun run lint` - Run ESLint on all files +- **Preview build**: `bun run preview` - Preview production build locally +- **Type checking**: `tsc -b` - Run TypeScript compiler to check types + +## Architecture + +This is a React TypeScript frontend for an Urbit application called Nostrill, which appears to integrate Nostr (decentralized social protocol) with Urbit (personal server). + +### Key Technologies +- **React 19** with TypeScript +- **Vite** for build tooling +- **Zustand** for global state management +- **TanStack Query** for server state and data fetching +- **Wouter** for routing +- **Urbit API** integration via custom packages in parent directories + +### Project Structure + +``` +src/ +├── components/ # UI components organized by feature +│ ├── composer/ # Post composition UI +│ ├── feed/ # Feed display components +│ ├── layout/ # Layout components (Sidebar, etc.) +│ ├── modals/ # Modal dialogs +│ └── post/ # Post display components and wrappers +├── logic/ # Business logic and utilities +│ ├── api.ts # Urbit connection setup +│ ├── nostrill.ts # Nostrill-specific logic +│ └── requests/ # API request handlers +├── pages/ # Route components (Feed, User, Settings) +├── state/ # Zustand store (state.ts) +├── styles/ # Styling and theming +├── types/ # TypeScript type definitions +└── Router.tsx # Main routing configuration +``` + +### State Management + +The application uses Zustand for state management (`src/state/state.ts`): +- Manages Urbit connection via `IO` class +- Stores Nostr events, user profiles, relay data +- Handles following/followers relationships +- Manages UI state (modals, composer data) + +### Urbit Integration + +- Connection established via `src/logic/api.ts` +- Uses Urbit Airlock/SSE for real-time updates +- Interacts with the `nostrill` desk on the Urbit ship +- Local packages used from parent directories: + - `urbit-api`: HTTP API client + - `urbit-ob`: Urbit ID utilities + - `urbit-sigils`: Visual ship identifiers + +### Path Aliases + +The project uses `@` alias for `src/` directory (configured in vite.config.ts). + +### Key Data Flows + +1. **Initialization**: App.tsx → state.init() → api.start() → Urbit connection +2. **State Updates**: Urbit SSE → IO subscriptions → Zustand store updates +3. **User Actions**: Components → IO methods → Urbit pokes/scries → State updates
\ No newline at end of file diff --git a/front/src/App.tsx b/front/src/App.tsx index f921bbf..415cb66 100644 --- a/front/src/App.tsx +++ b/front/src/App.tsx @@ -14,7 +14,10 @@ const queryClient = new QueryClient(); function App() { const [loading, setLoading] = useState(true); console.log("NOSTRILL INIT"); - const { init, modal } = useLocalState(); + const { init, modal } = useLocalState((s) => ({ + init: s.init, + modal: s.modal, + })); useEffect(() => { init().then((_res: any) => { setLoading(false); diff --git a/front/src/components/Avatar.tsx b/front/src/components/Avatar.tsx index 35b4386..0f3dc90 100644 --- a/front/src/components/Avatar.tsx +++ b/front/src/components/Avatar.tsx @@ -2,25 +2,41 @@ import useLocalState from "@/state/state"; import type { Ship } from "@/types/urbit"; import Sigil from "./Sigil"; import ShipModal from "./modals/ShipModal"; +import { isValidPatp } from "urbit-ob"; +import type { UserProfile } from "@/types/nostrill"; +import Icon from "@/components/Icon"; export default function ({ p, size, color, noClickOnName, + profile, + picOnly = false, }: { p: Ship; size: number; color?: string; noClickOnName?: boolean; + profile?: UserProfile; + picOnly?: boolean; }) { - const { setModal } = useLocalState(); + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); // TODO revisit this when %whom updates + const avatarInner = profile ? ( + <img src={profile.picture} /> + ) : isValidPatp(p) ? ( + <Sigil patp={p} size={size} bg={color} /> + ) : ( + <Icon name="comet" /> + ); const avatar = ( - <div className="avatar-w sigil cp" role="link" onClick={openModal}> - <Sigil patp={p} size={size} color={color} /> + <div className="avatar cp" onClick={openModal}> + {avatarInner} </div> ); + if (picOnly) return avatar; + const tooLong = (s: string) => (s.length > 15 ? " too-long" : ""); function openModal(e: React.MouseEvent) { if (noClickOnName) return; @@ -29,31 +45,12 @@ export default function ({ } const name = ( <div className="name cp" role="link" onMouseUp={openModal}> - <p className={"p-only" + tooLong(p)}>{p.length > 28 ? "Anon" : p}</p> - </div> - ); - return ( - <div className="ship-avatar"> - {avatar} - {name} - </div> - ); -} - -export function SigilOnly({ p, size, color }: any) { - const { setModal } = useLocalState(); - function openModal(e: React.MouseEvent) { - e.stopPropagation(); - setModal(<ShipModal ship={p} />); - } - return ( - <div - className="avatar-w sigil cp" - role="link" - onClick={openModal} - onMouseUp={openModal} - > - <Sigil patp={p} size={size} color={color} /> + {profile ? ( + <p>{profile.name}</p> + ) : ( + <p className={"p-only" + tooLong(p)}>{p.length > 28 ? "Anon" : p}</p> + )} </div> ); + return <div className="ship-avatar">{name}</div>; } diff --git a/front/src/components/Icon.tsx b/front/src/components/Icon.tsx new file mode 100644 index 0000000..a316e08 --- /dev/null +++ b/front/src/components/Icon.tsx @@ -0,0 +1,133 @@ +import { useTheme } from "@/styles/ThemeProvider"; + +import bellSvg from "@/assets/icons/bell.svg"; +import cometSvg from "@/assets/icons/comet.svg"; +import copySvg from "@/assets/icons/copy.svg"; +import crowSvg from "@/assets/icons/crow.svg"; +import emojiSvg from "@/assets/icons/emoji.svg"; +import homeSvg from "@/assets/icons/home.svg"; +import keySvg from "@/assets/icons/key.svg"; +import messagesSvg from "@/assets/icons/messages.svg"; +import nostrSvg from "@/assets/icons/nostr.svg"; +import palsSvg from "@/assets/icons/pals.svg"; +import profileSvg from "@/assets/icons/profile.svg"; +import quoteSvg from "@/assets/icons/quote.svg"; +import radioSvg from "@/assets/icons/radio.svg"; +import replySvg from "@/assets/icons/reply.svg"; +import repostSvg from "@/assets/icons/rt.svg"; +import rumorsSvg from "@/assets/icons/rumors.svg"; +import settingsSvg from "@/assets/icons/settings.svg"; +import youtubeSvg from "@/assets/icons/youtube.svg"; + +export type IconName = + | "bell" + | "comet" + | "copy" + | "crow" + | "emoji" + | "home" + | "key" + | "messages" + | "nostr" + | "pals" + | "profile" + | "quote" + | "radio" + | "reply" + | "repost" + | "rumors" + | "settings" + | "youtube"; + +const iconMap: Record<IconName, string> = { + bell: bellSvg, + comet: cometSvg, + copy: copySvg, + crow: crowSvg, + emoji: emojiSvg, + home: homeSvg, + key: keySvg, + messages: messagesSvg, + nostr: nostrSvg, + pals: palsSvg, + profile: profileSvg, + quote: quoteSvg, + radio: radioSvg, + reply: replySvg, + repost: repostSvg, + rumors: rumorsSvg, + settings: settingsSvg, + youtube: youtubeSvg, +}; + +interface IconProps { + name: IconName; + size?: number; + className?: string; + title?: string; + onClick?: (e?: React.MouseEvent) => void; + color?: "primary" | "text" | "textSecondary" | "textMuted" | "custom"; + customColor?: string; +} + +const Icon: React.FC<IconProps> = ({ + name, + size = 20, + className = "", + title, + onClick, + color = "text", + customColor, +}) => { + const { theme } = useTheme(); + + // Simple filter based on theme - icons should match text + const getFilter = () => { + // For dark themes, invert the black SVGs to white + if (theme.name === "dark" || theme.name === "noir" || theme.name === "gruvbox") { + return "invert(1)"; + } + // For light themes with dark text, keep as is + if (theme.name === "light") { + return "none"; + } + // For colored themes, adjust brightness/contrast + if (theme.name === "sepia") { + return "sepia(1) saturate(2) hue-rotate(20deg) brightness(0.8)"; + } + if (theme.name === "ocean") { + return "brightness(0) saturate(100%) invert(13%) sepia(95%) saturate(3207%) hue-rotate(195deg) brightness(94%) contrast(106%)"; + } + if (theme.name === "forest") { + return "brightness(0) saturate(100%) invert(24%) sepia(95%) saturate(1352%) hue-rotate(87deg) brightness(92%) contrast(96%)"; + } + return "none"; + }; + + const iconUrl = iconMap[name]; + + if (!iconUrl) { + console.error(`Icon "${name}" not found`); + return null; + } + + return ( + <img + src={iconUrl} + className={`icon ${className}`} + onClick={onClick} + title={title} + alt={title || name} + style={{ + width: size, + height: size, + display: "inline-block", + cursor: onClick ? "pointer" : "default", + filter: getFilter(), + transition: "filter 0.2s ease", + }} + /> + ); +}; + +export default Icon;
\ No newline at end of file diff --git a/front/src/components/ProfileEditor.tsx b/front/src/components/ProfileEditor.tsx new file mode 100644 index 0000000..9a7493f --- /dev/null +++ b/front/src/components/ProfileEditor.tsx @@ -0,0 +1,280 @@ +import { useState, useEffect } from "react"; +import type { UserProfile } from "@/types/nostrill"; +import useLocalState from "@/state/state"; +import Icon from "@/components/Icon"; +import toast from "react-hot-toast"; +import Avatar from "./Avatar"; + +interface ProfileEditorProps { + ship: string; + onSave?: () => void; +} + +const ProfileEditor: React.FC<ProfileEditorProps> = ({ ship, onSave }) => { + const { api, profiles } = useLocalState((s) => ({ + api: s.api, + profiles: s.profiles, + })); + const isOwnProfile = ship === api?.airlock.our; + + // Initialize state with existing profile or defaults + const existingProfile = profiles.get(ship); + const [name, setName] = useState(existingProfile?.name || ""); + const [picture, setPicture] = useState(existingProfile?.picture || ""); + const [about, setAbout] = useState(existingProfile?.about || ""); + const [customFields, setCustomFields] = useState< + Array<{ key: string; value: string }> + >( + Object.entries(existingProfile?.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + const [isEditing, setIsEditing] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + useEffect(() => { + const profile = profiles.get(ship); + if (profile) { + setName(profile.name || ""); + setPicture(profile.picture || ""); + setAbout(profile.about || ""); + setCustomFields( + Object.entries(profile.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + } + }, [ship, profiles]); + + const handleAddCustomField = () => { + setCustomFields([...customFields, { key: "", value: "" }]); + }; + + const handleUpdateCustomField = ( + index: number, + field: "key" | "value", + newValue: string, + ) => { + const updated = [...customFields]; + updated[index][field] = newValue; + setCustomFields(updated); + }; + + const handleRemoveCustomField = (index: number) => { + setCustomFields(customFields.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + setIsSaving(true); + try { + // Convert custom fields array to object + const other: Record<string, string> = {}; + customFields.forEach(({ key, value }) => { + if (key.trim()) { + other[key.trim()] = value; + } + }); + + const profile: UserProfile = { + name, + picture, + about, + other, + }; + + // Call API to save profile + if (api && typeof api.createProfile === "function") { + await api.createProfile(profile); + } else { + throw new Error("Profile update API not available"); + } + + toast.success("Profile updated successfully"); + setIsEditing(false); + onSave?.(); + } catch (error) { + toast.error("Failed to update profile"); + console.error("Failed to save profile:", error); + } finally { + setIsSaving(false); + } + }; + + const handleCancel = () => { + // Reset to original values + const profile = profiles.get(ship); + if (profile) { + setName(profile.name || ""); + setPicture(profile.picture || ""); + setAbout(profile.about || ""); + setCustomFields( + Object.entries(profile.other || {}).map(([key, value]) => ({ + key, + value, + })), + ); + } + setIsEditing(false); + }; + + if (!isOwnProfile) { + // View-only mode for other users' profiles - no editing allowed + return ( + <div className="profile-editor view-mode"> + <div className="profile-picture"> + <Avatar p={ship} size={120} picOnly={true} /> + </div> + <div className="profile-info"> + <h2>{name || ship}</h2> + {about && <p className="profile-about">{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> + ))} + </div> + )} + </div> + </div> + ); + } + + return ( + <div className="profile-editor"> + <div className="profile-header"> + <h2>Edit Profile</h2> + {isOwnProfile && !isEditing && ( + <button onClick={() => setIsEditing(true)} className="edit-btn"> + <Icon name="settings" size={16} /> + Edit + </button> + )} + </div> + + {isEditing ? ( + <div className="profile-form"> + <div className="form-group"> + <label htmlFor="name">Display Name</label> + <input + id="name" + type="text" + value={name} + onChange={(e) => setName(e.target.value)} + placeholder="Your display name" + /> + </div> + + <div className="form-group"> + <label htmlFor="picture">Profile Picture URL</label> + <input + id="picture" + type="url" + value={picture} + onChange={(e) => setPicture(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> + <div className="picture-preview"> + <Avatar p={ship} size={54} picOnly={true} /> + </div> + </div> + + <div className="form-group"> + <label htmlFor="about">About</label> + <textarea + id="about" + value={about} + onChange={(e) => setAbout(e.target.value)} + placeholder="Tell us about yourself..." + rows={4} + /> + </div> + + <div className="form-group custom-fields"> + <label>Custom Fields</label> + {customFields.map((field, index) => ( + <div key={index} className="custom-field-row"> + <input + type="text" + value={field.key} + onChange={(e) => + handleUpdateCustomField(index, "key", e.target.value) + } + placeholder="Field name" + className="field-key-input" + /> + <input + type="text" + value={field.value} + onChange={(e) => + handleUpdateCustomField(index, "value", e.target.value) + } + placeholder="Field value" + className="field-value-input" + /> + <button + onClick={() => handleRemoveCustomField(index)} + className="remove-field-btn" + title="Remove field" + > + × + </button> + </div> + ))} + <button onClick={handleAddCustomField} className="add-field-btn"> + + Add Custom Field + </button> + </div> + + <div className="form-actions"> + <button + onClick={handleSave} + disabled={isSaving} + className="save-btn" + > + {isSaving ? "Saving..." : "Save Profile"} + </button> + <button + onClick={handleCancel} + disabled={isSaving} + className="cancel-btn" + > + Cancel + </button> + </div> + </div> + ) : ( + <div className="profile-view"> + <div className="profile-picture"> + <Avatar p={ship} size={120} picOnly={true} /> + </div> + + <div className="profile-info"> + <h3>{name || ship}</h3> + {about && <p className="profile-about">{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> + ))} + </div> + )} + </div> + </div> + )} + </div> + ); +}; + +export default ProfileEditor; diff --git a/front/src/components/Sigil.tsx b/front/src/components/Sigil.tsx index 4978a72..cbc2e57 100644 --- a/front/src/components/Sigil.tsx +++ b/front/src/components/Sigil.tsx @@ -1,4 +1,4 @@ -import comet from "@/assets/icons/comet.svg"; +import Icon from "@/components/Icon"; import { auraToHex } from "@/logic/utils"; import { isValidPatp } from "urbit-ob"; import { sigil } from "urbit-sigils"; @@ -7,19 +7,19 @@ import { reactRenderer } from "urbit-sigils"; interface SigilProps { patp: string; size: number; - color?: string; + bg?: string; + fg?: string; } const Sigil = (props: SigilProps) => { - const color = props.color ? auraToHex(props.color) : "black"; - if (!isValidPatp(props.patp)) return <div className="sigil bad-sigil">X</div>; - else if (props.patp.length > 28) + const bg = props.bg ? auraToHex(props.bg) : "var(--color-background)"; + const fg = props.fg ? auraToHex(props.fg) : "var(--color-primary)"; + if (props.patp.length > 28) return ( - <img + <Icon + name="comet" + size={props.size} className="comet-icon" - src={comet} - alt="" - style={{ width: `${props.size}px`, height: `${props.size}px` }} /> ); else if (props.patp.length > 15) @@ -41,7 +41,7 @@ const Sigil = (props: SigilProps) => { patp: props.patp, renderer: reactRenderer, size: props.size, - colors: [color, "white"], + colors: [bg, fg], })} </> ); diff --git a/front/src/components/composer/Composer.tsx b/front/src/components/composer/Composer.tsx index 795188e..daa5af6 100644 --- a/front/src/components/composer/Composer.tsx +++ b/front/src/components/composer/Composer.tsx @@ -15,7 +15,10 @@ function Composer({ replying?: Poast; }) { const [loc, navigate] = useLocation(); - const { api, composerData } = useLocalState(); + const { api, composerData } = useLocalState((s) => ({ + api: s.api, + composerData: s.composerData, + })); const our = api!.airlock.our!; const [input, setInput] = useState(replying ? `${replying}: ` : ""); async function poast(e: FormEvent<HTMLFormElement>) { @@ -44,8 +47,8 @@ function Composer({ const placeHolder = isAnon ? "> be me" : "What's going on in Urbit"; return ( <form id="composer" onSubmit={poast}> - <div className="sigil"> - <Sigil patp={our} size={48} /> + <div className="sigil avatar"> + <Sigil patp={our} size={46} /> </div> {composerData && composerData.type === "reply" && ( diff --git a/front/src/components/layout/Sidebar.tsx b/front/src/components/layout/Sidebar.tsx index 4055454..d237fb5 100644 --- a/front/src/components/layout/Sidebar.tsx +++ b/front/src/components/layout/Sidebar.tsx @@ -1,20 +1,13 @@ import { RADIO, versionNum } from "@/logic/constants"; import { useLocation } from "wouter"; import useLocalState from "@/state/state"; -import key from "@/assets/icons/key.svg"; import logo from "@/assets/icons/logo.png"; -import home from "@/assets/icons/home.svg"; -import bell from "@/assets/icons/bell.svg"; -import settings from "@/assets/icons/settings.svg"; -import messages from "@/assets/icons/messages.svg"; -import profile from "@/assets/icons/profile.svg"; -import pals from "@/assets/icons/pals.svg"; -import rumors from "@/assets/icons/rumors.svg"; +import Icon from "@/components/Icon"; import { ThemeSwitcher } from "@/styles/ThemeSwitcher"; function SlidingMenu() { const [_, navigate] = useLocation(); - const { api } = useLocalState(); + const { api } = useLocalState((s) => ({ api: s.api })); function goto(to: string) { navigate(to); } @@ -26,21 +19,25 @@ function SlidingMenu() { </div> <h3>Feeds</h3> <div className="opt" role="link" onClick={() => goto(`/feed/global`)}> - <img src={home} alt="" /> + <Icon name="home" size={20} /> <div>Home</div> </div> <div className="opt" role="link" onClick={() => goto(`/hark`)}> - <img src={bell} alt="" /> + <Icon name="bell" size={20} /> <div>Activity</div> </div> <hr /> - <div className="opt" role="link" onClick={() => goto("/chat")}> - <img src={messages} alt="" /> + <div + className="opt tbd" + role="link" + // onClick={() => goto("/chat")} + > + <Icon name="messages" size={20} /> <div>Messages</div> </div> <div className="opt" role="link" onClick={() => goto("/pals")}> - <img src={pals} alt="" /> + <Icon name="pals" size={20} /> <div>Pals</div> </div> <hr /> @@ -49,29 +46,12 @@ function SlidingMenu() { role="link" onClick={() => goto(`/feed/${api!.airlock.our}`)} > - <img src={profile} alt="" /> + <Icon name="profile" size={20} /> <div>Profile</div> </div> - <div className="opt" role="link" onClick={() => goto("/feed/anon")}> - <img src={rumors} alt="" /> - <div>Rumors</div> - </div> - <hr /> - <div className="opt" role="link" onClick={() => goto("/radio")}> - <div className="img">{RADIO}</div> - <div>Radio</div> - </div> <hr /> - <div - className="opt" - role="link" - onClick={() => (window.location.href = "/cookies")} - > - <img src={key} alt="" /> - <div>Logins</div> - </div> <div className="opt" role="link" onClick={() => goto("/sets")}> - <img src={settings} alt="" /> + <Icon name="settings" size={20} /> <div>Settings</div> </div> <ThemeSwitcher /> diff --git a/front/src/components/modals/Modal.tsx b/front/src/components/modals/Modal.tsx index 7dd688c..e7bae78 100644 --- a/front/src/components/modals/Modal.tsx +++ b/front/src/components/modals/Modal.tsx @@ -2,7 +2,7 @@ import useLocalState from "@/state/state"; import { useEffect, useRef, useState } from "react"; function Modal({ children }: any) { - const { setModal } = useLocalState(); + const { setModal } = useLocalState((s) => ({ setModal: s.setModal })); function onKey(event: any) { if (event.key === "Escape") setModal(null); } diff --git a/front/src/components/modals/ShipModal.tsx b/front/src/components/modals/ShipModal.tsx index 86bffbb..e823a3a 100644 --- a/front/src/components/modals/ShipModal.tsx +++ b/front/src/components/modals/ShipModal.tsx @@ -1,13 +1,16 @@ import type { Ship } from "@/types/urbit"; import Modal from "./Modal"; import Avatar from "../Avatar"; -import copyIcon from "@/assets/icons/copy.svg"; +import Icon from "@/components/Icon"; import useLocalState from "@/state/state"; import { useLocation } from "wouter"; import toast from "react-hot-toast"; export default function ({ ship }: { ship: Ship }) { - const { setModal, api } = useLocalState(); + const { setModal, api } = useLocalState((s) => ({ + setModal: s.setModal, + api: s.api, + })); const [_, navigate] = useLocation(); function close() { setModal(null); @@ -22,12 +25,12 @@ export default function ({ ship }: { ship: Ship }) { <div id="ship-modal"> <div className="flex"> <Avatar p={ship} size={60} /> - <img + <Icon + name="copy" + size={20} className="copy-icon cp" - role="link" onClick={copy} - src={copyIcon} - alt="" + title="Copy ship name" /> </div> <div className="buttons f1"> diff --git a/front/src/components/post/Body.tsx b/front/src/components/post/Body.tsx index 2e4e2f8..e8b659c 100644 --- a/front/src/components/post/Body.tsx +++ b/front/src/components/post/Body.tsx @@ -6,7 +6,7 @@ import type { Media as MediaType, ExternalContent, } from "@/types/trill"; -import crow from "@/assets/icons/crow.svg"; +import Icon from "@/components/Icon"; import type { PostProps } from "./Post"; import Media from "./Media"; import JSONContent, { YoutubeSnippet } from "./External"; @@ -168,7 +168,7 @@ function Ref({ r, nest }: { r: Reference; nest: number }) { nest: nest + 1, className: "quote-in-post", })(Quote); - return <Card logo={crow}>{comp}</Card>; + return <Card logo="crow">{comp}</Card>; } return <></>; } diff --git a/front/src/components/post/Card.tsx b/front/src/components/post/Card.tsx index 37f4911..9309423 100644 --- a/front/src/components/post/Card.tsx +++ b/front/src/components/post/Card.tsx @@ -1,8 +1,11 @@ -export default function ({ children, logo, cn}: { cn?: string; logo: string; children: any }) { +import Icon from "@/components/Icon"; +import type { IconName } from "@/components/Icon"; + +export default function ({ children, logo, cn}: { cn?: string; logo: IconName; children: any }) { const className = "trill-post-card" + (cn ? ` ${cn}`: "") return ( <div className={className}> - <img src={logo} alt="" className="trill-post-card-logo" /> + <Icon name={logo} size={20} className="trill-post-card-logo" /> {children} </div> ); diff --git a/front/src/components/post/External.tsx b/front/src/components/post/External.tsx index 0ea1500..d52aec7 100644 --- a/front/src/components/post/External.tsx +++ b/front/src/components/post/External.tsx @@ -1,5 +1,4 @@ import type { ExternalContent } from "@/types/trill"; -import youtube from "@/assets/icons/youtube.svg"; import Card from "./Card"; interface JSONProps { @@ -32,7 +31,7 @@ export function YoutubeSnippet({ href, id }: { href: string; id: string }) { const thumbnail = `https://i.ytimg.com/vi/${id}/hqdefault.jpg`; // todo styiling return ( - <Card logo={youtube} cn="youtube-thumbnail"> + <Card logo="youtube" cn="youtube-thumbnail"> <a href={href}> <img src={thumbnail} alt="" /> </a> diff --git a/front/src/components/post/Footer.tsx b/front/src/components/post/Footer.tsx index 3b48241..3e4bbdc 100644 --- a/front/src/components/post/Footer.tsx +++ b/front/src/components/post/Footer.tsx @@ -1,7 +1,5 @@ import type { PostProps } from "./Post"; -import reply from "@/assets/icons/reply.svg"; -import quote from "@/assets/icons/quote.svg"; -import repost from "@/assets/icons/rt.svg"; +import Icon from "@/components/Icon"; import { useState } from "react"; import useLocalState from "@/state/state"; import { useLocation } from "wouter"; @@ -15,7 +13,11 @@ function Footer({ poast, refetch }: PostProps) { const [_showMenu, setShowMenu] = useState(false); const [location, navigate] = useLocation(); const [reposting, _setReposting] = useState(false); - const { api, setComposerData, setModal } = useLocalState(); + const { api, setComposerData, setModal } = useLocalState((s) => ({ + api: s.api, + setComposerData: s.setComposerData, + setModal: s.setModal, + })); const our = api!.airlock.our!; function doReply(e: React.MouseEvent) { e.stopPropagation(); @@ -126,13 +128,13 @@ function Footer({ poast, refetch }: PostProps) { <span role="link" onMouseUp={showReplyCount} className="reply-count"> {displayCount(childrenCount)} </span> - <img role="link" onMouseUp={doReply} src={reply} alt="" /> + <Icon name="reply" size={20} onClick={doReply} /> </div> <div className="icon"> <span role="link" onMouseUp={showQuoteCount} className="quote-count"> {displayCount(poast.engagement.quoted.length)} </span> - <img role="link" onMouseUp={doQuote} src={quote} alt="" /> + <Icon name="quote" size={20} onClick={doQuote} /> </div> <div className="icon"> <span @@ -145,15 +147,15 @@ function Footer({ poast, refetch }: PostProps) { {reposting ? ( <p>...</p> ) : myRP ? ( - <img - role="link" + <Icon + name="repost" + size={20} className="my-rp" - onMouseUp={cancelRP} - src={repost} + onClick={cancelRP} title="cancel repost" /> ) : ( - <img role="link" onMouseUp={sendRP} src={repost} title="repost" /> + <Icon name="repost" size={20} onClick={sendRP} title="repost" /> )} </div> <div className="icon" role="link" onMouseUp={doReact}> diff --git a/front/src/components/post/Header.tsx b/front/src/components/post/Header.tsx index e541fa5..4e72fe8 100644 --- a/front/src/components/post/Header.tsx +++ b/front/src/components/post/Header.tsx @@ -4,7 +4,7 @@ import { useLocation } from "wouter"; import useLocalState from "@/state/state"; function Header(props: PostProps) { const [_, navigate] = useLocation(); - const { profiles } = useLocalState(); + const profiles = useLocalState((s) => s.profiles); const profile = profiles.get(props.poast.author); // console.log("profile", profile); // console.log(props.poast.author.length, "length"); diff --git a/front/src/components/post/Loader.tsx b/front/src/components/post/Loader.tsx index f3c4715..a23bea1 100644 --- a/front/src/components/post/Loader.tsx +++ b/front/src/components/post/Loader.tsx @@ -14,7 +14,7 @@ function PostData(props: { nest?: number; // nested quotes className?: string; }) { - const { api } = useLocalState(); + const { api } = useLocalState((s) => ({ api: s.api })); const { host, id, nest } = props; const [enest, setEnest] = useState(nest); useEffect(() => { diff --git a/front/src/components/post/Post.tsx b/front/src/components/post/Post.tsx index e61efb0..277c119 100644 --- a/front/src/components/post/Post.tsx +++ b/front/src/components/post/Post.tsx @@ -47,7 +47,7 @@ export default Post; function TrillPost(props: PostProps) { const { poast, profile, fake } = props; - const { setModal } = useLocalState(); + const setModal = useLocalState((s) => s.setModal); const [_, navigate] = useLocation(); function openThread(_e: React.MouseEvent) { const sel = window.getSelection()?.toString(); @@ -64,7 +64,7 @@ function TrillPost(props: PostProps) { </div> ) : ( <div className="avatar sigil cp" role="link" onMouseUp={openModal}> - <Sigil patp={poast.author} size={42} /> + <Sigil patp={poast.author} size={46} /> </div> ); return ( diff --git a/front/src/components/post/Reactions.tsx b/front/src/components/post/Reactions.tsx index 58662cd..aabab61 100644 --- a/front/src/components/post/Reactions.tsx +++ b/front/src/components/post/Reactions.tsx @@ -14,7 +14,7 @@ import soy from "@/assets/reacts/soy.png"; import chad from "@/assets/reacts/chad.png"; import pika from "@/assets/reacts/pika.png"; import facepalm from "@/assets/reacts/facepalm.png"; -import emoji from "@/assets/icons/emoji.svg"; +import Icon from "@/components/Icon"; import emojis from "@/logic/emojis.json"; import Modal from "../modals/Modal"; import useLocalState from "@/state/state"; @@ -93,7 +93,7 @@ export function stringToReact(s: string) { if (s === "pepesad") return <img className="react-img" src={pepesad} alt="" />; if (s === "") - return <img className="react-img no-react" src={emoji} alt="" />; + return <Icon name="emoji" size={20} className="react-img no-react" />; if (s === "cringe") return <img className="react-img" src={cringe} alt="" />; if (s === "cry") return <img className="react-img" src={cry} alt="" />; if (s === "crywojak") return <img className="react-img" src={cry} alt="" />; diff --git a/front/src/components/post/wrappers/Nostr.tsx b/front/src/components/post/wrappers/Nostr.tsx index bdc5ba9..2782fb8 100644 --- a/front/src/components/post/wrappers/Nostr.tsx +++ b/front/src/components/post/wrappers/Nostr.tsx @@ -4,7 +4,7 @@ import useLocalState from "@/state/state"; export default NostrPost; function NostrPost({ data }: { data: NostrPost }) { - const { profiles } = useLocalState(); + const { profiles } = useLocalState((s) => ({ profiles: s.profiles })); const profile = profiles.get(data.event.pubkey); return <Post poast={data.post} profile={profile} />; diff --git a/front/src/components/post/wrappers/NostrIcon.tsx b/front/src/components/post/wrappers/NostrIcon.tsx index 0c368fb..30fbfe9 100644 --- a/front/src/components/post/wrappers/NostrIcon.tsx +++ b/front/src/components/post/wrappers/NostrIcon.tsx @@ -1,9 +1,12 @@ -import nostrIcon from "@/assets/icons/nostr.svg"; +import Icon from "@/components/Icon"; import useLocalState from "@/state/state"; import toast from "react-hot-toast"; import type { Poast } from "@/types/trill"; export default function ({ poast }: { poast: Poast }) { - const { relays, api, keys } = useLocalState(); + const { relays, api } = useLocalState((s) => ({ + relays: s.relays, + api: s.api, + })); async function sendToRelay(e: React.MouseEvent) { e.stopPropagation(); @@ -16,7 +19,7 @@ export default function ({ poast }: { poast: Poast }) { return ( <div className="icon" role="link" onMouseUp={sendToRelay}> - <img role="link" src={nostrIcon} title="repost" /> + <Icon name="nostr" size={20} title="relay to nostr" /> </div> ); } diff --git a/front/src/logic/api.ts b/front/src/logic/api.ts index 52635e5..cf44073 100644 --- a/front/src/logic/api.ts +++ b/front/src/logic/api.ts @@ -1,6 +1,6 @@ import Urbit from "urbit-api"; -export const URL = import.meta.env.PROD ? "" : "http://localhost:8080"; +export const URL = import.meta.env.PROD ? "" : "http://localhost:8083"; export async function start(): Promise<Urbit> { const airlock = new Urbit(URL, ""); diff --git a/front/src/logic/requests/nostrill.ts b/front/src/logic/requests/nostrill.ts index 6334c34..74fcb87 100644 --- a/front/src/logic/requests/nostrill.ts +++ b/front/src/logic/requests/nostrill.ts @@ -1,16 +1,26 @@ import type Urbit from "urbit-api"; -import type { Cursor, PostID } from "@/types/trill"; +import type { Cursor, FC, PostID } from "@/types/trill"; import type { Ship } from "@/types/urbit"; import { FeedPostCount } from "../constants"; import type { UserProfile } from "@/types/nostrill"; +import type { AsyncRes } from "@/types/ui"; // Subscribe type Handler = (date: any) => void; export default class IO { airlock; + subs: Map<string, number> = new Map(); constructor(airlock: Urbit) { this.airlock = airlock; } + private async thread(threadName: string, json: any) { + return this.airlock.thread({ + body: json, + inputMark: "json", + outputMark: "json", + threadName, + }); + } private async poke(json: any) { return this.airlock.poke({ app: "nostrill", mark: "json", json }); } @@ -18,10 +28,15 @@ export default class IO { return this.airlock.scry({ app: "nostrill", path }); } private async sub(path: string, handler: Handler) { + const has = this.subs.get(path); + if (has) return; + const err = (err: any, _id: string) => console.log(err, "error on nostrill subscription"); - const quit = (data: any) => + const quit = (data: any) => { console.log(data, "nostrill subscription kicked"); + this.subs.delete(path); + }; const res = await this.airlock.subscribe({ app: "nostrill", path, @@ -29,6 +44,7 @@ export default class IO { err, quit, }); + this.subs.set(path, res); console.log(res, "subscribed to nostrill agent"); } async unsub(sub: number) { @@ -115,23 +131,50 @@ export default class IO { return await this.poke({ fols: json }); } // profiles - async createProfile(pubkey: string, profile: UserProfile) { - const json = { add: { pubkey, profile } }; + async createProfile(profile: UserProfile) { + const json = { add: profile }; return await this.poke({ prof: json }); } - async createKey() { - const json = { add: null }; - return await this.poke({ keys: json }); + async deleteProfile() { + const json = { del: null }; + return await this.poke({ prof: json }); } - async removeKey(pubkey: string) { - const json = { del: pubkey }; - return await this.poke({ keys: json }); + async cycleKeys() { + return await this.poke({ keys: null }); } // relaying + async addRelay(url: string) { + const json = { add: url }; + return await this.poke({ rela: json }); + } + async deleteRelay(url: string) { + const json = { del: url }; + return await this.poke({ rela: json }); + } + async syncRelays() { + // TODO make it choosable? + const json = { sync: null }; + return await this.poke({ rela: json }); + } async relayPost(host: string, id: string, relays: string[]) { const json = { send: { host, id, relays } }; return await this.poke({ rela: json }); } + // threads + // + async peekFeed(host: string): AsyncRes<FC> { + try { + const json = { begs: { feed: host } }; + const res: any = await this.thread("beg", json); + console.log("peeking feed", res); + if (!("begs" in res)) return { error: "wrong request" }; + if ("ng" in res.begs) return { error: res.begs.ng }; + if (!("feed" in res.begs.ok)) return { error: "wrong request" }; + else return { ok: res.begs.ok.feed }; + } catch (e) { + return { error: `${e}` }; + } + } } // notifications diff --git a/front/src/main.tsx b/front/src/main.tsx index 5d4a2be..9200210 100644 --- a/front/src/main.tsx +++ b/front/src/main.tsx @@ -3,7 +3,7 @@ import { createRoot } from "react-dom/client"; import App from "./App.tsx"; createRoot(document.getElementById("root")!).render( - <StrictMode> - <App /> - </StrictMode>, + // <StrictMode> + <App />, + // </StrictMode>, ); 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; diff --git a/front/src/state/state.ts b/front/src/state/state.ts index 01b8ea1..2e747ea 100644 --- a/front/src/state/state.ts +++ b/front/src/state/state.ts @@ -6,6 +6,7 @@ import { create } from "zustand"; import type { UserProfile } from "@/types/nostrill"; import type { Event } from "@/types/nostr"; import type { FC, Poast } from "@/types/trill"; +import { useShallow } from "zustand/shallow"; // TODO handle airlock connection issues // the SSE pipeline has a "status-update" event FWIW // type AirlockState = "connecting" | "connected" | "failed"; @@ -27,7 +28,7 @@ export type LocalState = { }; const creator = create<LocalState>(); -const useLocalState = creator((set, get) => ({ +export const useStore = creator((set, get) => ({ isNew: false, api: null, init: async () => { @@ -78,4 +79,8 @@ const useLocalState = creator((set, get) => ({ setComposerData: (composerData) => set({ composerData }), })); -export default useLocalState; +const useShallowStore = <T extends (state: LocalState) => any>( + selector: T, +): ReturnType<T> => useStore(useShallow(selector)); + +export default useShallowStore; diff --git a/front/src/styles/ProfileEditor.css b/front/src/styles/ProfileEditor.css new file mode 100644 index 0000000..c1b65e5 --- /dev/null +++ b/front/src/styles/ProfileEditor.css @@ -0,0 +1,325 @@ +.profile-editor { + align-items: center; + padding: 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 20px; +} + +.profile-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.profile-header h2 { + margin: 0; + color: var(--color-text); +} + +.edit-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.edit-btn:hover { + opacity: 0.9; +} + +.profile-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.form-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +.form-group label { + font-weight: 500; + color: var(--color-text); +} + +.form-group input, +.form-group textarea { + padding: 10px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: var(--color-primary); +} + +.picture-preview { + width: 100px; + height: 100px; + border-radius: 50%; + overflow: hidden; + border: 2px solid var(--color-border); + margin-top: 10px; +} + +.picture-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.custom-fields { + display: flex; + flex-direction: column; + gap: 10px; +} + +.custom-field-row { + display: flex; + gap: 10px; + align-items: center; +} + +.field-key-input, +.field-value-input { + flex: 1; + padding: 8px; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-background); + color: var(--color-text); +} + +.remove-field-btn { + padding: 4px 8px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 0.2s; + font-size: 16px; + font-weight: bold; + min-width: 28px; + height: 28px; +} + +.remove-field-btn:hover { + opacity: 0.8; +} + +.add-field-btn { + padding: 10px; + background: transparent; + color: var(--color-primary); + border: 1px dashed var(--color-primary); + border-radius: 4px; + cursor: pointer; + transition: background 0.2s; +} + +.add-field-btn:hover { + background: var(--color-surface); +} + +.form-actions { + display: flex; + gap: 10px; + margin-top: 20px; +} + +.save-btn, +.cancel-btn { + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; + transition: opacity 0.2s; +} + +.save-btn { + background: var(--color-primary); + color: white; +} + +.cancel-btn { + background: var(--color-surface-hover); + color: var(--color-text); +} + +.save-btn:disabled, +.cancel-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.save-btn:hover:not(:disabled), +.cancel-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.profile-view, +.view-mode { + display: flex; + gap: 20px; +} + +.profile-picture { + width: 120px; + height: 120px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--color-border); + flex-shrink: 0; +} + +.profile-picture img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.profile-info { + flex: 1; +} + +.profile-info h3 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.profile-about { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 20px; +} + +.profile-custom-fields { + margin-top: 20px; +} + +.profile-custom-fields h4 { + margin: 0 0 10px 0; + color: var(--color-text); +} + +.custom-field-view { + display: flex; + gap: 10px; + margin-bottom: 8px; +} + +.field-key { + font-weight: 500; + color: var(--color-text); +} + +.field-value { + color: var(--color-text-secondary); +} + +/* User Actions */ +.user-actions { + display: flex; + gap: 12px; + margin-bottom: 20px; + padding: 16px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.action-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border: 1px solid; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: all 0.2s ease; + background: transparent; +} + +.action-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.action-btn.follow { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.action-btn.follow:hover:not(:disabled) { + background: var(--color-primary); + color: white; +} + +.action-btn.following { + border-color: var(--color-success); + color: var(--color-success); + background: var(--color-success); + color: white; +} + +.action-btn.following:hover:not(:disabled) { + background: var(--color-error); + border-color: var(--color-error); +} + +.action-btn.access { + border-color: var(--color-secondary); + color: var(--color-secondary); +} + +.action-btn.access:hover:not(:disabled) { + background: var(--color-secondary); + color: white; +} + +/* Empty feed message */ +.empty-feed-message { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 60px 20px; + background: var(--color-surface); + border-radius: 8px; + border: 1px solid var(--color-border); +} + +.empty-feed-message h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 20px; +} + +.empty-feed-message p { + color: var(--color-text-secondary); + line-height: 1.5; + max-width: 400px; +}
\ No newline at end of file diff --git a/front/src/styles/Settings.css b/front/src/styles/Settings.css new file mode 100644 index 0000000..bb1f46e --- /dev/null +++ b/front/src/styles/Settings.css @@ -0,0 +1,339 @@ +.settings-page { + max-width: 800px; + margin: 0 auto; + padding: 20px; +} + +.settings-header { + margin-bottom: 30px; + padding-bottom: 20px; + border-bottom: 1px solid var(--color-border); +} + +.settings-header h1 { + margin: 0 0 8px 0; + color: var(--color-text); + font-size: 32px; + font-weight: 600; +} + +.settings-header p { + margin: 0; + color: var(--color-text-secondary); + font-size: 16px; +} + +.settings-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +/* Settings Sections */ +.settings-section { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 12px; + overflow: hidden; +} + +.section-header { + display: flex; + align-items: center; + gap: 12px; + padding: 20px 24px; + background: var(--color-background); + border-bottom: 1px solid var(--color-border); +} + +.section-header h2 { + margin: 0; + color: var(--color-text); + font-size: 20px; + font-weight: 600; +} + +.section-content { + padding: 0; +} + +/* Setting Items */ +.setting-item { + display: flex; + justify-content: space-between; + align-items: flex-start; + padding: 24px; + gap: 20px; +} + +.setting-item:not(:last-child) { + border-bottom: 1px solid var(--color-border-light); +} + +.setting-info { + flex: 1; + min-width: 0; +} + +.setting-info label { + display: block; + margin-bottom: 4px; + color: var(--color-text); + font-size: 16px; + font-weight: 500; +} + +.setting-info p { + margin: 0; + color: var(--color-text-secondary); + font-size: 14px; + line-height: 1.4; +} + +.setting-control { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 12px; +} + +/* Key Display */ +.key-display { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + max-width: 400px; +} + +.pubkey { + flex: 1; + padding: 10px 12px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + color: var(--color-text); + font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 12px; + word-break: break-all; + line-height: 1.3; + min-width: 0; +} + +.cycle-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.cycle-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.cycle-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Relay Management */ +.relay-list { + width: 100%; + max-width: 500px; +} + +.no-relays { + display: flex; + flex-direction: column; + align-items: center; + padding: 30px 20px; + text-align: center; + color: var(--color-text-muted); +} + +.no-relays p { + margin: 12px 0 0 0; + color: var(--color-text-muted); +} + +.relay-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 8px; + margin-bottom: 8px; + transition: border-color 0.2s; +} + +.relay-item:hover { + border-color: var(--color-primary); +} + +.relay-info { + flex: 1; + min-width: 0; +} + +.relay-url { + display: block; + color: var(--color-text); + font-size: 14px; + font-weight: 500; + word-break: break-all; + margin-bottom: 2px; +} + +.relay-status { + display: inline-block; + color: var(--color-success); + font-size: 12px; + padding: 2px 6px; + background: var(--color-surface); + border-radius: 3px; + border: 1px solid var(--color-success); +} + +.remove-relay-btn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: var(--color-error); + color: white; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: opacity 0.2s; + flex-shrink: 0; +} + +.remove-relay-btn:hover { + opacity: 0.8; +} + +/* Add Relay Form */ +.add-relay-form { + margin-top: 16px; + padding-top: 16px; + border-top: 1px solid var(--color-border-light); +} + +.relay-input-group { + display: flex; + gap: 8px; + width: 100%; +} + +.relay-input { + flex: 1; + padding: 10px 12px; + border: 1px solid var(--color-border); + border-radius: 6px; + background: var(--color-background); + color: var(--color-text); + font-size: 14px; +} + +.relay-input:focus { + outline: none; + border-color: var(--color-primary); +} + +.relay-input::placeholder { + color: var(--color-text-muted); +} + +.add-relay-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 500; + transition: opacity 0.2s; + white-space: nowrap; +} + +.add-relay-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.add-relay-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .settings-page { + padding: 16px; + } + + .setting-item { + flex-direction: column; + align-items: stretch; + gap: 16px; + } + + .setting-control { + width: 100%; + justify-content: stretch; + } + + .key-display { + max-width: none; + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .pubkey { + text-align: center; + } + + .relay-input-group { + flex-direction: column; + } + + .section-header { + padding: 16px 20px; + } + + .setting-item { + padding: 20px; + } +} + +@media (max-width: 480px) { + .settings-header h1 { + font-size: 28px; + } + + .section-header h2 { + font-size: 18px; + } + + .settings-page { + padding: 12px; + } +}
\ No newline at end of file diff --git a/front/src/styles/ThemeSwitcher.css b/front/src/styles/ThemeSwitcher.css index 518a00d..6b48545 100644 --- a/front/src/styles/ThemeSwitcher.css +++ b/front/src/styles/ThemeSwitcher.css @@ -153,6 +153,7 @@ position: absolute; top: calc(100% + var(--spacing-xs)); right: 0; + left: 0; min-width: 180px; background-color: var(--color-background); border: 1px solid var(--color-border); @@ -168,6 +169,7 @@ opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); @@ -231,6 +233,7 @@ /* Reduced motion */ @media (prefers-reduced-motion: reduce) { + .theme-switcher-compact, .theme-button, .theme-dropdown-toggle, @@ -238,11 +241,11 @@ .dropdown-arrow { transition: none; } - + .theme-dropdown-menu { animation: none; } - + .theme-switcher-compact:hover { transform: none; } diff --git a/front/src/styles/feed.css b/front/src/styles/feed.css index 417f94b..05f0bb2 100644 --- a/front/src/styles/feed.css +++ b/front/src/styles/feed.css @@ -1,4 +1,139 @@ +.avatar { + border: 1px solid var(--color-text); +} + .avatar, .avatar img { - width: 64px; + width: 48px; + height: 48px; +} + +/* Nostr Feed Styles */ +.nostr-empty-state { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + padding: 40px 20px; +} + +.empty-content { + text-align: center; + max-width: 400px; +} + +.empty-content h3 { + margin: 20px 0 10px 0; + color: var(--color-text); + font-size: 24px; +} + +.empty-content p { + color: var(--color-text-secondary); + line-height: 1.5; + margin-bottom: 30px; +} + +.resync-btn { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 12px 24px; + background: var(--color-primary); + color: white; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 16px; + font-weight: 500; + transition: opacity 0.2s ease; +} + +.resync-btn:hover:not(:disabled) { + opacity: 0.9; +} + +.resync-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.nostr-feed { + width: 100%; +} + +.nostr-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: var(--color-surface); + border-radius: 8px; + margin-bottom: 16px; + border: 1px solid var(--color-border); +} + +.feed-info { + display: flex; + align-items: center; + gap: 12px; +} + +.feed-info h4 { + margin: 0; + color: var(--color-text); + font-size: 18px; +} + +.post-count { + color: var(--color-text-secondary); + font-size: 14px; + background: var(--color-background); + padding: 4px 8px; + border-radius: 4px; + border: 1px solid var(--color-border); +} + +.resync-btn-small { + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + background: var(--color-background); + border: 1px solid var(--color-border); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--color-text); +} + +.resync-btn-small:hover:not(:disabled) { + background: var(--color-surface-hover); + border-color: var(--color-primary); +} + +.resync-btn-small:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.btn-spinner, +.btn-spinner-small { + width: 16px; + height: 16px; + animation: spin 1s linear infinite; +} + +.btn-spinner-small { + width: 14px; + height: 14px; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } }
\ No newline at end of file diff --git a/front/src/styles/styles.css b/front/src/styles/styles.css index c2b05d6..ede283d 100644 --- a/front/src/styles/styles.css +++ b/front/src/styles/styles.css @@ -211,11 +211,16 @@ h6 { gap: 1rem; margin: 1rem 0; + & img { width: 24px; height: 24px; } } + + .opt.tbd { + opacity: 0.4; + } } & main { diff --git a/front/src/types/ui.ts b/front/src/types/ui.ts index c0c61a1..4596236 100644 --- a/front/src/types/ui.ts +++ b/front/src/types/ui.ts @@ -1,6 +1,9 @@ import type { NostrMetadata } from "./nostrill"; import type { Poast } from "./trill"; import type { Tweet } from "./twatter"; +import type { Ship } from "./urbit"; +export type Result<T> = { ok: T } | { error: string }; +export type AsyncRes<T> = Promise<Result<T>>; export type Timestamp = number; export type UrbitTime = string; |