From ec20eb996b423eda84d1d0c6ae304a68950f1a60 Mon Sep 17 00:00:00 2001 From: gosangam Date: Sun, 31 May 2026 00:26:05 +0530 Subject: [PATCH] ui enhancements --- app/chat/page.tsx | 51 + app/u/[handle]/collections/page.tsx | 27 + app/u/[handle]/layout.tsx | 117 + app/u/[handle]/mentions/page.tsx | 25 + app/u/[handle]/page.tsx | 126 +- app/u/[handle]/posts/page.tsx | 10 +- app/u/[handle]/profile-context.tsx | 19 + app/u/me/layout.tsx | 9 +- app/u/me/posts/page.tsx | 4 +- components/site-bottom-nav.tsx | 16 +- components/site-sidebar.tsx | 12 +- components/social/post-actions-footer.tsx | 21 +- components/social/post-scroll-card.tsx | 40 +- package-lock.json | 9103 --------------------- package.json | 1 + yarn.lock | 687 +- 16 files changed, 951 insertions(+), 9317 deletions(-) create mode 100644 app/chat/page.tsx create mode 100644 app/u/[handle]/collections/page.tsx create mode 100644 app/u/[handle]/layout.tsx create mode 100644 app/u/[handle]/mentions/page.tsx create mode 100644 app/u/[handle]/profile-context.tsx delete mode 100644 package-lock.json diff --git a/app/chat/page.tsx b/app/chat/page.tsx new file mode 100644 index 00000000..30d3c85b --- /dev/null +++ b/app/chat/page.tsx @@ -0,0 +1,51 @@ +"use client"; + +/** + * /chat — placeholder for the Kautilya AI study companion. + * + * The actual chat surface (an AI tuned on Śrīla Prabhupāda's books, + * letters, and lectures) isn't built yet. Until it ships we render a + * coming-soon page so the nav entries (sidebar + bottom bar) have a + * real route to point at. When the feature lands, swap this file for + * the real chat UI — nav wiring stays the same. + */ + +import Link from "next/link"; +import { Compass } from "lucide-react"; +import { KautilyaIcon } from "@/components/selection-toolbar/shared"; + +export default function ChatPage() { + return ( +
+
+ + + + +

+ Kautilya is coming soon +

+ +

+ An AI study companion tuned on Śrīla Prabhupāda's books, + letters, and lectures — ask any question, get answers grounded in + his own words with citations back to the source passages. Until + it lands, the full library is already waiting. +

+ +
+ + + Explore Knowledgebase + +
+
+
+ ); +} diff --git a/app/u/[handle]/collections/page.tsx b/app/u/[handle]/collections/page.tsx new file mode 100644 index 00000000..9b451126 --- /dev/null +++ b/app/u/[handle]/collections/page.tsx @@ -0,0 +1,27 @@ +"use client"; + +/** + * /u/[handle]/collections — public collections for a profile. + * + * The list comes from the layout's profile fetch (`public_collections`), + * so this page only has to decide between the empty state and the grid. + * Layout owns the ProfileHeader + tabs + container. + */ + +import { CollectionViews } from "@/components/social/collection-views"; +import { usePublicProfile } from "../profile-context"; + +export default function PublicProfileCollectionsPage() { + const data = usePublicProfile(); + const list = data.public_collections ?? []; + + if (list.length === 0) { + return ( +

+ No public collections yet. +

+ ); + } + + return `/c/${c.id}`} />; +} diff --git a/app/u/[handle]/layout.tsx b/app/u/[handle]/layout.tsx new file mode 100644 index 00000000..d98274cc --- /dev/null +++ b/app/u/[handle]/layout.tsx @@ -0,0 +1,117 @@ +"use client"; + +/** + * /u/[handle] shared layout — public profile shell. + * + * Mirrors /u/me's layout so visitors see the same ProfileHeader + tab + * strip whether they're on the grid (`/u/`) or the scroll feed + * (`/u//posts`). Without this layout, /u//posts rendered a bare + * `
` with extra padding — visually disconnected from /u/ and + * narrower than expected on mobile. + * + * Profile data is fetched once here and exposed via PublicProfileContext + * so child pages (which need `public_collections`, etc.) don't refetch. + * + * If the logged-in user lands on their own /u/, redirect to + * /u/me — preserving the `/posts` sub-path when present. + */ + +import { use, useEffect, useState } from "react"; +import { notFound, usePathname, useRouter } from "next/navigation"; +import { AtSign, FolderClosed, LayoutGrid } from "lucide-react"; +import { profile, SocialError, type PublicProfile } from "@/lib/social"; +import { useAuth } from "@/components/auth/auth-provider"; +import { ProfileHeader } from "@/components/profile/profile-header"; +import { + ProfileTabs, + type ProfileTab, +} from "@/components/profile/profile-tabs"; +import { PublicProfileContext } from "./profile-context"; + +export default function PublicProfileLayout(props: { + params: Promise<{ handle: string }>; + children: React.ReactNode; +}) { + const { handle } = use(props.params); + const router = useRouter(); + const pathname = usePathname(); + const { user } = useAuth(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [missing, setMissing] = useState(false); + + const isOwn = !!user && user.handle.toLowerCase() === handle.toLowerCase(); + + useEffect(() => { + if (!isOwn) return; + // Preserve whatever sub-path the visitor is on so /u//collections + // jumps to /u/me/collections (and so on for /posts, /mentions). + const m = pathname.match(/^\/u\/[^/]+(\/.*)?$/); + const suffix = m?.[1] ?? ""; + router.replace(`/u/me${suffix}`); + }, [isOwn, router, pathname]); + + useEffect(() => { + if (isOwn) return; + let cancelled = false; + setLoading(true); + profile + .get(handle) + .then((p) => { + if (!cancelled) setData(p); + }) + .catch((err) => { + if (err instanceof SocialError && err.status === 404) setMissing(true); + }) + .finally(() => !cancelled && setLoading(false)); + return () => { + cancelled = true; + }; + }, [handle, isOwn]); + + if (missing) notFound(); + if (isOwn) return null; + + if (loading || !data) { + return ( +
+

Loading profile…

+
+ ); + } + + const tabs: ProfileTab[] = [ + { + href: `/u/${data.handle}`, + label: "Posts", + icon: , + activeFor: [`/u/${data.handle}/posts`], + }, + { + href: `/u/${data.handle}/collections`, + label: "Collections", + icon: , + }, + { + href: `/u/${data.handle}/mentions`, + label: "Tagged", + icon: , + }, + ]; + + return ( +
+ + + +
{props.children}
+
+
+ ); +} diff --git a/app/u/[handle]/mentions/page.tsx b/app/u/[handle]/mentions/page.tsx new file mode 100644 index 00000000..2f8d4c23 --- /dev/null +++ b/app/u/[handle]/mentions/page.tsx @@ -0,0 +1,25 @@ +"use client"; + +/** + * /u/[handle]/mentions — posts that tag this user. + * + * Mirror of /u/me/mentions: the tab is wired into the public profile + * shell so visitors see an empty state instead of a 404. Will swap to + * a real feed once the Mentions feature ships. + */ + +import { AtSign } from "lucide-react"; +import { usePublicProfile } from "../profile-context"; + +export default function PublicProfileMentionsPage() { + const data = usePublicProfile(); + return ( +
+ +

+ Posts that tag @{data.handle}{" "} + will appear here. Coming soon. +

+
+ ); +} diff --git a/app/u/[handle]/page.tsx b/app/u/[handle]/page.tsx index 93099a06..7b07493e 100644 --- a/app/u/[handle]/page.tsx +++ b/app/u/[handle]/page.tsx @@ -1,131 +1,55 @@ "use client"; /** - * Public profile at /u/[handle]. + * Public profile — Posts tab (default). * - * Anonymous-friendly. Mirrors /u/me's shell — ProfileHeader at the top, - * a smaller tab strip (Posts + Collections only; Bookmarks and Mentions - * are private), then a grid of the active tab's content. - * - * Owner-handle redirect: when the logged-in user lands on their own - * /u/, we send them to /u/me where the owner controls live. + * The /u/[handle] layout owns the ProfileHeader + tab strip + container; + * this page only renders the post grid. Tapping a thumbnail jumps to + * /u/[handle]/posts#post- where the Instagram-style scroll feed + * shows the full passages. */ import { use, useEffect, useState } from "react"; -import Link from "next/link"; -import { notFound, useRouter } from "next/navigation"; -import { FolderClosed, LayoutGrid } from "lucide-react"; -import { - profile, - SocialError, - type Post, - type PublicProfile, -} from "@/lib/social"; -import { ProfileHeader } from "@/components/profile/profile-header"; -import { - ProfileTabs, - type ProfileTab, -} from "@/components/profile/profile-tabs"; +import { profile, type Post } from "@/lib/social"; import { PostViews } from "@/components/social/post-views"; -import { CollectionViews } from "@/components/social/collection-views"; -import { useAuth } from "@/components/auth/auth-provider"; +import { usePublicProfile } from "./profile-context"; -type Tab = "posts" | "collections"; - -export default function PublicProfilePage(props: { +export default function PublicProfilePostsPage(props: { params: Promise<{ handle: string }>; - searchParams?: Promise<{ tab?: string }>; }) { const { handle } = use(props.params); - const search = props.searchParams ? use(props.searchParams) : undefined; - const tab: Tab = search?.tab === "collections" ? "collections" : "posts"; - const router = useRouter(); - const { user } = useAuth(); - const [data, setData] = useState(null); + const data = usePublicProfile(); const [feed, setFeed] = useState([]); const [loading, setLoading] = useState(true); - const [notFoundState, setNotFound] = useState(false); - - const isOwn = !!user && user.handle.toLowerCase() === handle.toLowerCase(); - useEffect(() => { - if (isOwn) router.replace("/u/me"); - }, [isOwn, router]); useEffect(() => { - if (isOwn) return; let cancelled = false; setLoading(true); - Promise.all([profile.get(handle), profile.feed(handle, 30)]) - .then(([p, posts]) => { - if (cancelled) return; - setData(p); - setFeed(posts ?? []); + profile + .feed(handle, 30) + .then((rows) => { + if (!cancelled) setFeed(rows ?? []); }) - .catch((err) => { - if (err instanceof SocialError && err.status === 404) { - setNotFound(true); - } + .catch(() => { + // Layout already 404'd if the handle is unknown; if the feed + // itself fails we just show an empty grid rather than blocking + // the page with an error toast. }) .finally(() => !cancelled && setLoading(false)); return () => { cancelled = true; }; - }, [handle, isOwn]); + }, [handle]); - if (notFoundState) notFound(); - if (isOwn) return null; - - if (loading || !data) { - return ( -
-

Loading profile…

-
- ); + if (loading) { + return

Loading…

; } - const tabs: ProfileTab[] = [ - { - href: `/u/${data.handle}`, - label: "Posts", - icon: , - }, - { - href: `/u/${data.handle}?tab=collections`, - label: "Collections", - icon: , - }, - ]; - return ( -
- - -
- {tab === "collections" ? ( - data.public_collections && data.public_collections.length > 0 ? ( - `/c/${c.id}`} - /> - ) : ( -

- No public collections yet. -

- ) - ) : ( - `/u/${data.handle}/posts#post-${p.id}`} - emptyMessage="No posts yet — quotes shared by this user will appear here." - /> - )} -
-
+ `/u/${data.handle}/posts#post-${p.id}`} + emptyMessage="No posts yet — quotes shared by this user will appear here." + /> ); } diff --git a/app/u/[handle]/posts/page.tsx b/app/u/[handle]/posts/page.tsx index 6d2ef283..01a3b6fa 100644 --- a/app/u/[handle]/posts/page.tsx +++ b/app/u/[handle]/posts/page.tsx @@ -6,6 +6,12 @@ * Mirrors /u/me/posts but read-only (no Delete / Update background). The * grid thumbnail on /u/[handle] links here with a `#post-` hash so * the feed scrolls to the tapped post on mount. + * + * The parent /u/[handle]/layout owns the ProfileHeader + tab strip + the + * outer max-w-3xl container, so this page only needs the inner scroll + * column. The max-w-xl cap matches /u/me/posts so the column reads at + * the same width on desktop; on mobile both pages fall back to viewport + * width with the layout-supplied gutters. */ import { use, useEffect, useRef, useState } from "react"; @@ -57,7 +63,7 @@ export default function PublicPostsFeedPage(props: { if (missing) notFound(); return ( -
+
{loading ? (

Loading…

) : feed.length === 0 ? ( @@ -65,6 +71,6 @@ export default function PublicPostsFeedPage(props: { ) : ( feed.map((p) => ) )} -
+ ); } diff --git a/app/u/[handle]/profile-context.tsx b/app/u/[handle]/profile-context.tsx new file mode 100644 index 00000000..a4ce7b67 --- /dev/null +++ b/app/u/[handle]/profile-context.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { PublicProfile } from "@/lib/social"; + +/** Set by the /u/[handle] layout after it fetches the profile. Child + * pages read it to render content (e.g. the Collections tab needs the + * profile's `public_collections` array) without a second fetch. */ +export const PublicProfileContext = createContext(null); + +export function usePublicProfile(): PublicProfile { + const v = useContext(PublicProfileContext); + if (!v) { + throw new Error( + "usePublicProfile must be used inside the /u/[handle] layout", + ); + } + return v; +} diff --git a/app/u/me/layout.tsx b/app/u/me/layout.tsx index 41178de7..7701c505 100644 --- a/app/u/me/layout.tsx +++ b/app/u/me/layout.tsx @@ -14,7 +14,7 @@ */ import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { AtSign, Bookmark, @@ -30,6 +30,11 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { const { user, loading } = useAuth(); const { requireAuth } = useAuthGate(); const router = useRouter(); + const searchParams = useSearchParams(); + // /u/me?edit=1 swaps the Posts grid for the inline edit form (see + // app/u/me/page.tsx). The tab strip would just be in the way while the + // user is editing — hide it for that one URL state. + const editMode = searchParams.get("edit") === "1"; useEffect(() => { if (loading || user) return; @@ -86,7 +91,7 @@ export default function MeLayout({ children }: { children: React.ReactNode }) { joinedAt={user.created_at} editHref="/u/me?edit=1" /> - + {editMode ? null : }
{children}
); diff --git a/app/u/me/posts/page.tsx b/app/u/me/posts/page.tsx index 115d27ce..2179ce4c 100644 --- a/app/u/me/posts/page.tsx +++ b/app/u/me/posts/page.tsx @@ -77,7 +77,7 @@ export default function MyPostsFeedPage() { if (!user) return null; return ( -
+
{error ? (

{error}

) : loading ? ( @@ -103,6 +103,6 @@ export default function MyPostsFeedPage() { confirmLabel="Delete post" onConfirm={performDelete} /> -
+ ); } diff --git a/components/site-bottom-nav.tsx b/components/site-bottom-nav.tsx index 9f61d756..1e18e561 100644 --- a/components/site-bottom-nav.tsx +++ b/components/site-bottom-nav.tsx @@ -3,7 +3,7 @@ /** * SiteBottomNav — mobile-only bottom tab bar. * - * Four items: Home (Feed) · Knowledgebase · Search · Account. + * Four items: Home · Search · Kautilya · Account. * Mirrors the Instagram pattern — fixed to the viewport bottom, full * width, equal-width tabs, the active tab uses the primary color. * @@ -14,11 +14,13 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Home, Search, User as UserIcon } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; +import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { cn } from "@/lib/utils"; type Item = { - href: string; + /** Tab label, used for the aria-label. Bottom nav is icon-only. */ label: string; + href: string; icon: React.ReactNode; matchPrefixes?: string[]; }; @@ -48,6 +50,11 @@ export function SiteBottomNav() { "/disciples", ], }, + { + href: "/chat", + label: "Kautilya", + icon: , + }, { href: user ? "/u/me" : "/login", label: user ? "You" : "Sign in", @@ -79,7 +86,7 @@ export function SiteBottomNav() { {items.map((item) => { const active = isActive(item); return ( -
  • +
  • {item.icon} - - {item.label} -
  • ); diff --git a/components/site-sidebar.tsx b/components/site-sidebar.tsx index d4e65de5..b1565d79 100644 --- a/components/site-sidebar.tsx +++ b/components/site-sidebar.tsx @@ -6,12 +6,12 @@ * Layout (collapsed, w-16): * * ┌──┐ - * │🏠│ ← top group (Home / Knowledgebase / Collections / - * │📚│ Bookmarks / Mentions / Search) + * │🏠│ ← top group (Home / Search / Kautilya / + * │🔍│ Collections / Bookmarks / Mentions) + * │✦ │ * │📁│ * │🔖│ * │@ │ - * │🔍│ * │ │ * │⋮ │ ← bottom group (Reading settings, Profile) * │👤│ @@ -39,6 +39,7 @@ import { } from "lucide-react"; import { useAuth } from "@/components/auth/auth-provider"; import { ReadingSettings } from "@/components/reading-settings"; +import { KautilyaIcon } from "@/components/selection-toolbar/shared"; import { cn } from "@/lib/utils"; type NavItem = { @@ -73,6 +74,11 @@ const TOP_ITEMS: NavItem[] = [ "/disciples", ], }, + { + href: "/chat", + label: "Kautilya", + icon: , + }, { href: "/u/me/collections", label: "Collections", diff --git a/components/social/post-actions-footer.tsx b/components/social/post-actions-footer.tsx index fbce7d63..f5ddbb55 100644 --- a/components/social/post-actions-footer.tsx +++ b/components/social/post-actions-footer.tsx @@ -16,13 +16,24 @@ */ import { useState } from "react"; -import { FolderPlus, Heart, MessageCircle, Repeat2 } from "lucide-react"; +import { + HiOutlineArrowPath, + HiOutlineChatBubbleOvalLeft, + HiOutlineFolderPlus, + HiOutlineHeart, +} from "react-icons/hi2"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { useAuthGate } from "@/components/auth/auth-gate"; import { useMediaQuery } from "@/components/selection-toolbar/shared"; import { CollectionsDrawer } from "@/components/selection-toolbar/collections-drawer"; +// All four icons come from the Heroicons v2 outline family (react-icons +// `hi2` namespace), so they share one design language — uniform stroke +// weight, matching corner radius, matching optical sizing. No more +// Lucide cross-icon visual drift. +const ICON_CLASS = "size-[1.375rem]"; + export function PostActionsFooter({ postID }: { postID: string }) { const { requireAuth } = useAuthGate(); const isMobile = useMediaQuery("(max-width: 767px)"); @@ -44,7 +55,7 @@ export function PostActionsFooter({ postID }: { postID: string }) { toast("Likes are coming soon", { duration: 2000 }); }) } - icon={} + icon={} /> } + icon={} /> } + icon={} /> withAuth(() => setCollectionsOpen(true))} - icon={} + icon={} /> diff --git a/components/social/post-scroll-card.tsx b/components/social/post-scroll-card.tsx index e59f4f6d..e0b36533 100644 --- a/components/social/post-scroll-card.tsx +++ b/components/social/post-scroll-card.tsx @@ -26,7 +26,7 @@ * always visible on touch (mobile). */ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { ImageIcon, Trash2 } from "lucide-react"; @@ -248,22 +248,46 @@ function Caption({ expanded: boolean; onToggle: () => void; }) { - // Show 3 lines collapsed. The "more" toggle appears only when the text - // is long enough to warrant it (a cheap char-length check beats a - // measure-and-compare pass for our purposes — captions over ~180 chars - // will always overflow 3 lines at the rendered font size). - const longEnough = text.length > 180; + // We clamp to 3 lines collapsed and expose a "More" toggle only when + // the clamp is actually hiding something. A char-length heuristic gets + // this wrong: a 200-char caption can fit cleanly in 3 lines on a wide + // card (no clipping → "More" would be a false promise). Instead measure + // scrollHeight vs clientHeight on the clamped

    . Once we've observed + // overflow we latch it on so "Show less" stays visible after expansion + // (where scrollHeight would equal clientHeight and we'd otherwise lose + // the way back). + const pRef = useRef(null); + const [overflowed, setOverflowed] = useState(false); + + useEffect(() => { + if (expanded) return; + const el = pRef.current; + if (!el) return; + const measure = () => { + // Sub-pixel rounding can leave a 1px gap even when content fits; + // require a real difference before flagging overflow. + setOverflowed(el.scrollHeight - el.clientHeight > 1); + }; + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [text, expanded]); + + const showToggle = overflowed || expanded; + return (

    “{text}”

    - {longEnough ? ( + {showToggle ? (