diff --git a/.env.example b/.env.example index 03ef410..c55a3bd 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,7 @@ PROFILES_BASE_URL=https://profiles.minnect.com # Feed host: home feed, posts, likes, comments (used by later tasks). FEED_BASE_URL=https://feed.sharedservices.vt.com + +# Discovery host: public, read-only, unauthenticated. Featured experts, +# trending minnects and categories. No token is ever sent to it. +DISCOVER_BASE_URL=https://api.minnect.com diff --git a/src/app/api/discover/route.ts b/src/app/api/discover/route.ts new file mode 100644 index 0000000..30da477 --- /dev/null +++ b/src/app/api/discover/route.ts @@ -0,0 +1,18 @@ +import { NextResponse } from "next/server"; +import { getDiscover } from "@/lib/discover"; + +// GET /api/discover +// Same-origin proxy for Minnect's PUBLIC discovery data. No session needed. +// Returns { featuredExperts, trendingMinnects, categories }, public fields only. +// Upstream failure -> 502 { error: "discover_unavailable" } (never empty arrays, +// so the UI can tell "nothing to show" from "could not load"). + +export const runtime = "nodejs"; + +export async function GET() { + const result = await getDiscover(); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json(result.data); +} diff --git a/src/app/api/experts/search/route.ts b/src/app/api/experts/search/route.ts new file mode 100644 index 0000000..b002665 --- /dev/null +++ b/src/app/api/experts/search/route.ts @@ -0,0 +1,21 @@ +import { NextResponse } from "next/server"; +import { browseCategory, searchExperts } from "@/lib/experts-search"; + +// GET /api/experts/search?q= or ?category= +// Same-origin proxy for Minnect's PUBLIC expert list. EXPERTS ONLY: it never +// calls a user-search endpoint. Public fields only. No session needed. +// Upstream failure -> 502 { error: "experts_unavailable" }. + +export const runtime = "nodejs"; + +export async function GET(req: Request) { + const url = new URL(req.url); + const q = (url.searchParams.get("q") ?? "").trim().slice(0, 80); + const category = (url.searchParams.get("category") ?? "").trim().slice(0, 80); + if (!q && !category) return NextResponse.json({ experts: [] }); + const result = q ? await searchExperts(q) : await browseCategory(category); + if (!result.ok) { + return NextResponse.json({ error: result.error }, { status: result.status }); + } + return NextResponse.json({ experts: result.experts }); +} diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts index 7b101d8..d19fb22 100644 --- a/src/app/api/session/route.ts +++ b/src/app/api/session/route.ts @@ -1,13 +1,14 @@ import { NextResponse, type NextRequest } from "next/server"; import { loginToMinnect, LoginError } from "@/lib/minnect"; import { createSession, getSession } from "@/lib/session-store"; -import { endSession } from "@/lib/session-refresh"; import { SESSION_COOKIE, sessionCookieOptions } from "@/lib/cookie"; // This route holds the session on the server. The browser talks only to this // app; Minnect tokens never leave the server (ADR-001). // -// POST signs in, GET says who the person is, DELETE signs out (revoke + clear). +// POST signs in, GET says who the person is. There is no sign-out: the session ends +// when the server restarts or the cookie is cleared, and Minnect's revoke endpoint +// is never called (the refresh token is shared across the person's devices). // The reusable token-refresh logic lives in session-refresh.ts for the feed // route (a later task) to call before it uses a token. @@ -91,21 +92,3 @@ export async function GET(req: NextRequest) { feedUserId: session.person.feedUserId ?? null, }); } - -/** - * DELETE /api/session - * Sign out: revoke the refresh token at Minnect (best-effort), delete the - * server session, and clear the cookie. Always returns 200: sign-out succeeds - * even when revoke errors. After this, the session is gone, so every - * token-requiring route (and GET /api/session) returns 401 { error: "sign_in" }. - */ -export async function DELETE(req: NextRequest) { - const sessionId = req.cookies.get(SESSION_COOKIE)?.value; - - await endSession(sessionId); - - const res = NextResponse.json({ ok: true }); - // Expire the cookie with the same attributes it was set with. - res.cookies.set(SESSION_COOKIE, "", { ...sessionCookieOptions(), maxAge: 0 }); - return res; -} diff --git a/src/app/globals.css b/src/app/globals.css index d6e1e15..9a573ca 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -79,3 +79,50 @@ button:disabled { opacity: .5; cursor: not-allowed; } .menu button:hover { background: #f4f4f4; } .menu .menu-danger { color: #a8141b; } .menu-ask { font-size: 13px; color: var(--grey); padding: 4px 8px; } + +/* 3-column signed-in layout: nav | feed | rails. Wraps on narrow widths. */ +.columns { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-start; max-width: 1240px; margin: 0 auto; padding: 0 16px; } +.col-left { flex: 0 0 180px; position: sticky; top: 72px; padding-top: 16px; } +.col-left > ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; } +.col-left a { display: block; padding: 10px 12px; border-radius: 8px; color: var(--ink); text-decoration: none; font-weight: 500; } +.col-left a[aria-current="page"] { background: var(--ink); color: #fff; } +.col-center { flex: 1 1 400px; min-width: 0; } +.col-center .feed { padding-left: 0; padding-right: 0; } +.col-right { flex: 0 0 300px; position: sticky; top: 72px; max-height: calc(100vh - 88px); overflow-y: auto; padding-top: 16px; display: flex; flex-direction: column; gap: 20px; } +.rail h2 { margin: 0 0 8px; font-size: 16px; font-weight: 700; } +.rail-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; } +.rail-card { background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 12px; display: flex; gap: 10px; } +.expert-card { align-items: center; } +.minnect-card { flex-direction: column; } +.rail-text { display: flex; flex-direction: column; min-width: 0; font-size: 14px; } +.rail-name { font-size: 14px; } +.rail-byline { color: var(--grey); font-size: 13px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rail-meta { color: var(--grey); font-size: 13px; } +.minnect-who { display: flex; align-items: center; gap: 8px; } +.avatar-sm { width: 28px; height: 28px; font-size: 11px; } +.minnect-thumb { width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: 8px; background: var(--line); } +.minnect-question { margin: 0; font-size: 14px; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.minnect-video { background: #000; border-radius: 0; } +.minnect-audio { width: 100%; height: 36px; } +@media (max-width: 959px) { + .col-left { flex: 1 1 100%; position: static; padding-top: 8px; } + .col-left > ul { flex-direction: row; } + .col-center { flex: 1 1 100%; order: 3; } + .col-right { flex: 1 1 100%; order: 2; position: static; max-height: none; overflow: visible; padding-top: 0; } + .col-right .rail-list { flex-direction: row; overflow-x: auto; padding-bottom: 8px; } + .col-right .rail-card { flex: 0 0 260px; } +} + +/* Expert search + category chips (left column). Squared corners, monochrome + red. */ +.expert-search { margin-top: 16px; } +.expert-search summary { font-weight: 700; font-size: 16px; cursor: pointer; padding: 4px 0; } +.expert-search form { display: flex; flex-direction: column; } +.expert-search input { border-radius: 0; width: 100%; } +.chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; } +.chip { margin: 0; padding: 6px 10px; border-radius: 0; background: #fff; color: var(--ink); border: 1px solid var(--ink); font-size: 13px; } +.chip[aria-pressed="true"] { background: var(--red); border-color: var(--red); color: #fff; } +.search-results { margin-top: 12px; } +.search-note { margin: 0; font-size: 14px; color: var(--grey); } +@media (max-width: 959px) { + .col-left .expert-search { flex-basis: 100%; } +} diff --git a/src/components/App.tsx b/src/components/App.tsx index 2c0a05f..804e83a 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,16 +1,18 @@ "use client"; +import Link from "next/link"; import { useEffect, useState } from "react"; -import { loadSession, signOut, type Person } from "@/lib/auth-client"; +import { loadSession, type Person } from "@/lib/auth-client"; import { SignInForm } from "./SignInForm"; import { Logo } from "./Logo"; import { Feed } from "./Feed"; +import { DiscoverRails } from "./DiscoverRails"; +import { ExpertSearch } from "./ExpertSearch"; type State = { kind: "loading" } | { kind: "out" } | { kind: "in"; person: Person }; export function App() { const [state, setState] = useState({ kind: "loading" }); - const [signingOut, setSigningOut] = useState(false); useEffect(() => { let live = true; @@ -22,14 +24,6 @@ export function App() { }; }, []); - async function handleSignOut() { - if (signingOut) return; - setSigningOut(true); - await signOut(); - setSigningOut(false); - setState({ kind: "out" }); - } - if (state.kind === "loading") { return (
@@ -54,11 +48,21 @@ export function App() { {state.person.name} {state.person.email} - - setState({ kind: "out" })} /> +
+ +
+ setState({ kind: "out" })} /> +
+ +
); } diff --git a/src/components/DiscoverRails.tsx b/src/components/DiscoverRails.tsx new file mode 100644 index 0000000..b33989b --- /dev/null +++ b/src/components/DiscoverRails.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { DiscoverData } from "@/lib/discover"; +import { fetchDiscover } from "@/lib/discover-client"; +import { FeaturedExpertsRail } from "./FeaturedExpertsRail"; +import { TrendingMinnectsRail } from "./TrendingMinnectsRail"; + +export function DiscoverRailsView({ data }: { data: DiscoverData | null }) { + if (!data) return null; + return ( + <> + + + + ); +} + +/** Loads /api/discover once. Failure hides the rails; the feed is unaffected. */ +export function DiscoverRails() { + const [data, setData] = useState(null); + useEffect(() => { + let live = true; + fetchDiscover().then((d) => { if (live) setData(d); }, () => {}); + return () => { live = false; }; + }, []); + return ; +} diff --git a/src/components/ExpertSearch.tsx b/src/components/ExpertSearch.tsx new file mode 100644 index 0000000..eb7e30d --- /dev/null +++ b/src/components/ExpertSearch.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { DiscoverCategory } from "@/lib/discover"; +import { fetchDiscover } from "@/lib/discover-client"; +import type { ExpertResult } from "@/lib/experts-search"; +import { fetchExperts } from "@/lib/experts-search-client"; +import { initials } from "@/lib/feed-model"; + +export type SearchStatus = "idle" | "loading" | "ready" | "error"; + +export interface ExpertSearchViewProps { + query: string; + onQuery: (q: string) => void; + categories: DiscoverCategory[]; + activeCategory: string | null; + onCategory: (name: string | null) => void; + status: SearchStatus; + results: ExpertResult[]; + defaultOpen?: boolean; +} + +export function ExpertSearchView(p: ExpertSearchViewProps) { + return ( +
+ Find experts +
e.preventDefault()}> + + p.onQuery(e.target.value)} + /> +
+ {p.categories.length > 0 ? ( +
+ {p.categories.map((c) => ( + + ))} +
+ ) : null} +
+ {p.status === "loading" ?

Searching...

: null} + {p.status === "error" ?

Search is unavailable. Try again.

: null} + {p.status === "ready" && p.results.length === 0 ? ( +

No experts found.

+ ) : null} + {p.status === "ready" && p.results.length > 0 ? ( +
    + {p.results.map((e) => ( +
  • + {e.pictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
    + {e.displayName || "Expert"} + {e.byline ? {e.byline} : null} + {e.rating !== null ? ★ {e.rating.toFixed(1)} : null} +
    +
  • + ))} +
+ ) : null} +
+
+ ); +} + +const DEBOUNCE_MS = 300; + +export function ExpertSearch() { + const [query, setQuery] = useState(""); + const [category, setCategory] = useState(null); + const [categories, setCategories] = useState([]); + const [status, setStatus] = useState("idle"); + const [results, setResults] = useState([]); + const [open, setOpen] = useState(true); + const seq = useRef(0); + + useEffect(() => { + setOpen(window.matchMedia("(min-width: 960px)").matches); + let live = true; + fetchDiscover().then( + (d) => { if (live) setCategories(d.categories); }, + () => {}, + ); + return () => { live = false; }; + }, []); + + useEffect(() => { + const q = query.trim(); + const mine = ++seq.current; + if (!q && !category) { + setStatus("idle"); + setResults([]); + return; + } + setStatus("loading"); + const t = setTimeout(() => { + fetchExperts(q ? { q } : { category: category ?? undefined }).then( + (r) => { if (seq.current === mine) { setResults(r); setStatus("ready"); } }, + () => { if (seq.current === mine) { setResults([]); setStatus("error"); } }, + ); + }, q ? DEBOUNCE_MS : 0); + return () => clearTimeout(t); + }, [query, category]); + + return ( + + ); +} diff --git a/src/components/FeaturedExpertsRail.tsx b/src/components/FeaturedExpertsRail.tsx new file mode 100644 index 0000000..a057e91 --- /dev/null +++ b/src/components/FeaturedExpertsRail.tsx @@ -0,0 +1,38 @@ +import type { DiscoverExpert } from "@/lib/discover"; +import { initials } from "@/lib/feed-model"; + +function Rating({ rating, reviews }: { rating: number | null; reviews: number | null }) { + if (rating === null && reviews === null) return null; + return ( + + {rating !== null ? ★ {rating.toFixed(1)} : null} + {reviews !== null ? ({reviews} {reviews === 1 ? "review" : "reviews"}) : null} + + ); +} + +export function FeaturedExpertsRail({ experts }: { experts: DiscoverExpert[] }) { + if (experts.length === 0) return null; + return ( +
+

Featured experts

+
    + {experts.map((e) => ( +
  • + {e.pictureUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + + )} +
    + {e.displayName || "Expert"} + {e.byline ? {e.byline} : null} + +
    +
  • + ))} +
+
+ ); +} diff --git a/src/components/MinnectMedia.tsx b/src/components/MinnectMedia.tsx new file mode 100644 index 0000000..c315e25 --- /dev/null +++ b/src/components/MinnectMedia.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import type { DiscoverMedia } from "@/lib/discover"; + +interface Props { + media: DiscoverMedia | null; + thumbnailUrl: string | null; + question: string | null; + who: string; +} + +/** Video answers play inline; audio keeps the audio control; no url shows the thumbnail only. */ +export function MinnectMedia({ media, thumbnailUrl, question, who }: Props) { + const [videoFailed, setVideoFailed] = useState(false); + + const thumb = thumbnailUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + {question + ) : null; + + if (media?.kind === "video" && !videoFailed) { + return ( +