From 985fa2f7c99832cdf3c3351d2273c8fd05402b78 Mon Sep 17 00:00:00 2001 From: polwex Date: Wed, 17 Sep 2025 21:45:18 +0700 Subject: basic comms working --- front/src/components/Avatar.tsx | 53 ++--- front/src/components/Icon.tsx | 133 +++++++++++ front/src/components/ProfileEditor.tsx | 280 +++++++++++++++++++++++ front/src/components/Sigil.tsx | 20 +- front/src/components/composer/Composer.tsx | 9 +- front/src/components/layout/Sidebar.tsx | 46 ++-- front/src/components/modals/Modal.tsx | 2 +- front/src/components/modals/ShipModal.tsx | 15 +- front/src/components/post/Body.tsx | 4 +- front/src/components/post/Card.tsx | 7 +- front/src/components/post/External.tsx | 3 +- front/src/components/post/Footer.tsx | 24 +- front/src/components/post/Header.tsx | 2 +- front/src/components/post/Loader.tsx | 2 +- front/src/components/post/Post.tsx | 4 +- front/src/components/post/Reactions.tsx | 4 +- front/src/components/post/wrappers/Nostr.tsx | 2 +- front/src/components/post/wrappers/NostrIcon.tsx | 9 +- 18 files changed, 511 insertions(+), 108 deletions(-) create mode 100644 front/src/components/Icon.tsx create mode 100644 front/src/components/ProfileEditor.tsx (limited to 'front/src/components') 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 ? ( + + ) : isValidPatp(p) ? ( + + ) : ( + + ); const avatar = ( -
- +
+ {avatarInner}
); + 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 = (
-

{p.length > 28 ? "Anon" : p}

-
- ); - return ( -
- {avatar} - {name} -
- ); -} - -export function SigilOnly({ p, size, color }: any) { - const { setModal } = useLocalState(); - function openModal(e: React.MouseEvent) { - e.stopPropagation(); - setModal(); - } - return ( -
- + {profile ? ( +

{profile.name}

+ ) : ( +

{p.length > 28 ? "Anon" : p}

+ )}
); + return
{name}
; } 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 = { + 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 = ({ + 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 ( + {title + ); +}; + +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 = ({ 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 = {}; + 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 ( +
+
+ +
+
+

{name || ship}

+ {about &&

{about}

} + + {customFields.length > 0 && ( +
+

Additional Info

+ {customFields.map(({ key, value }, index) => ( +
+ {key}: + {value} +
+ ))} +
+ )} +
+
+ ); + } + + return ( +
+
+

Edit Profile

+ {isOwnProfile && !isEditing && ( + + )} +
+ + {isEditing ? ( +
+
+ + setName(e.target.value)} + placeholder="Your display name" + /> +
+ +
+ + setPicture(e.target.value)} + placeholder="https://example.com/avatar.jpg" + /> +
+ +
+
+ +
+ +