summaryrefslogtreecommitdiff
path: root/front
diff options
context:
space:
mode:
Diffstat (limited to 'front')
-rw-r--r--front/CLAUDE.md72
-rw-r--r--front/src/App.tsx5
-rw-r--r--front/src/components/Avatar.tsx53
-rw-r--r--front/src/components/Icon.tsx133
-rw-r--r--front/src/components/ProfileEditor.tsx280
-rw-r--r--front/src/components/Sigil.tsx20
-rw-r--r--front/src/components/composer/Composer.tsx9
-rw-r--r--front/src/components/layout/Sidebar.tsx46
-rw-r--r--front/src/components/modals/Modal.tsx2
-rw-r--r--front/src/components/modals/ShipModal.tsx15
-rw-r--r--front/src/components/post/Body.tsx4
-rw-r--r--front/src/components/post/Card.tsx7
-rw-r--r--front/src/components/post/External.tsx3
-rw-r--r--front/src/components/post/Footer.tsx24
-rw-r--r--front/src/components/post/Header.tsx2
-rw-r--r--front/src/components/post/Loader.tsx2
-rw-r--r--front/src/components/post/Post.tsx4
-rw-r--r--front/src/components/post/Reactions.tsx4
-rw-r--r--front/src/components/post/wrappers/Nostr.tsx2
-rw-r--r--front/src/components/post/wrappers/NostrIcon.tsx9
-rw-r--r--front/src/logic/api.ts2
-rw-r--r--front/src/logic/requests/nostrill.ts63
-rw-r--r--front/src/main.tsx6
-rw-r--r--front/src/pages/Feed.tsx84
-rw-r--r--front/src/pages/Settings.tsx257
-rw-r--r--front/src/pages/User.tsx138
-rw-r--r--front/src/state/state.ts9
-rw-r--r--front/src/styles/ProfileEditor.css325
-rw-r--r--front/src/styles/Settings.css339
-rw-r--r--front/src/styles/ThemeSwitcher.css7
-rw-r--r--front/src/styles/feed.css137
-rw-r--r--front/src/styles/styles.css5
-rw-r--r--front/src/types/ui.ts3
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;