diff --git a/.env.example b/.env.example index 499393c..150e123 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,21 @@ NEXT_PUBLIC_INSFORGE_BASE_URL=https://your-app.region.insforge.app NEXT_PUBLIC_INSFORGE_ANON_KEY=your-anon-key-here + +# ─── TACK Search (web search backend) ───────────────────────────────────────── +# TACK's /api/tack/search endpoint queries a real web search API. +# +# Default provider: Serper.dev (Google Search results — title, snippet, link). +# Sign up at https://serper.dev, create an API key, and paste it here. +# Pricing (as of 2026): 2,500 free queries, then ~$0.30–$1.00 per 1,000 +# queries depending on plan. This key is already used by the existing +# /summarize, /read, and /search chat commands. +SERPER_API_KEY=your-serper-api-key-here +# +# Alternative provider: Google Programmable Search JSON API. +# The Bing Web Search API was retired by Microsoft in August 2025, so Google's +# Programmable Search is the recommended drop-in alternative. To use it, +# swap the serperSearch() call in src/app/api/tack/search/route.ts and set: +# Docs: https://developers.google.com/custom-search/v1/overview +# Pricing: 100 queries/day free, then $5 per 1,000 queries (max 10k/day). +# GOOGLE_SEARCH_API_KEY=your-google-api-key-here +# GOOGLE_SEARCH_CX=your-programmable-search-engine-id diff --git a/src/app/(protected)/chat/page.tsx b/src/app/(protected)/chat/page.tsx index a6a8231..fc9bc1b 100644 --- a/src/app/(protected)/chat/page.tsx +++ b/src/app/(protected)/chat/page.tsx @@ -1,8 +1,10 @@ "use client"; import { useEffect, useRef } from "react"; +import Link from "next/link"; import { ChatHistory, ChatInput } from "@/components/chat"; import { LiveRegion } from "@/components/a11y"; +import { Button } from "@/components/ui/button"; import { useChat } from "@/hooks/useChat"; //import { useVoice } from "@/hooks/useVoice"; diff --git a/src/app/api/tack/search/route.ts b/src/app/api/tack/search/route.ts new file mode 100644 index 0000000..a70b5d5 --- /dev/null +++ b/src/app/api/tack/search/route.ts @@ -0,0 +1,150 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@insforge/nextjs/server"; +import { z } from "zod"; +import { serperSearch } from "@/lib/serper"; +import { checkRateLimit } from "@/lib/rate-limit"; + +/** + * TACK search API — an accessibility-first web search endpoint. + * + * Backend choice: this reuses the repo's existing Serper.dev integration + * (`SERPER_API_KEY`), which returns Google search results (title, snippet, + * link) — exactly the fields TACK needs. Serper was chosen over the two APIs + * named in the brief because: + * • Microsoft retired the Bing Web Search API in August 2025. + * • Serper is already wired up and keyed in this codebase, so no new + * credential or dependency is required. + * To swap in Google's Programmable Search JSON API instead, replace the + * `serperSearch` call below and set GOOGLE_SEARCH_API_KEY / GOOGLE_SEARCH_CX + * (see .env.example). + * + * Every response — success OR error — carries a plain-text `message` field + * written to be read aloud by a screen reader. + */ + +const searchSchema = z.object({ + q: z.string().trim().min(1, "Please type something to search for.").max(500), +}); + +// How many results TACK returns per search. Kept modest so a screen-reader +// user can page through the whole list with arrow keys without fatigue. +const RESULTS_PER_SEARCH = 8; + +export interface TackResult { + title: string; + snippet: string; + url: string; + source: string; +} + +interface TackSearchResponseBody { + query: string; + count: number; + results: TackResult[]; + message: string; +} + +/** Extract a clean, human-readable source domain from a result URL. */ +function sourceDomain(link: string): string { + try { + return new URL(link).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +export async function POST(request: NextRequest) { + // ── Auth ────────────────────────────────────────────────────────────── + let userId: string | null = null; + try { + const session = await auth(); + userId = session.userId; + } catch { + userId = null; + } + if (!userId) { + return NextResponse.json( + { error: "You need to be signed in to search." }, + { status: 401 } + ); + } + + // ── Rate limit ──────────────────────────────────────────────────────── + const { allowed } = checkRateLimit(`tack-search:${userId}`, 30, 60000); + if (!allowed) { + return NextResponse.json( + { error: "Too many searches. Please wait a moment and try again." }, + { status: 429 } + ); + } + + // ── Parse & validate ────────────────────────────────────────────────── + let body: unknown; + try { + body = await request.json(); + } catch { + return NextResponse.json( + { error: "Please type something to search for." }, + { status: 400 } + ); + } + + const parsed = searchSchema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { + error: + parsed.error.issues[0]?.message ?? + "Please type something to search for.", + }, + { status: 400 } + ); + } + + const query = parsed.data.q; + + // ── Search ──────────────────────────────────────────────────────────── + let organic: Awaited>["organic"]; + try { + const data = await serperSearch(query, RESULTS_PER_SEARCH); + organic = data.organic ?? []; + } catch (err) { + console.error("TACK search failed:", err); + return NextResponse.json( + { + error: + "Search is temporarily unavailable. Please try again in a moment.", + }, + { status: 502 } + ); + } + + const results: TackResult[] = organic + .filter((r) => r.title && r.link) + .map((r) => ({ + title: r.title, + snippet: r.snippet ?? "", + url: r.link, + source: sourceDomain(r.link), + })); + + if (results.length === 0) { + const emptyBody: TackSearchResponseBody = { + query, + count: 0, + results: [], + message: `No results found for “${query}”. Try different or more general words.`, + }; + return NextResponse.json(emptyBody, { status: 200 }); + } + + const responseBody: TackSearchResponseBody = { + query, + count: results.length, + results, + message: `${results.length} result${ + results.length === 1 ? "" : "s" + } found for “${query}”.`, + }; + return NextResponse.json(responseBody, { status: 200 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index f414620..2b5ae86 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -501,7 +501,8 @@ html { display: flex; align-items: center; justify-content: space-between; - max-width: 1200px; + max-width: 100%; + padding: 0 2rem; margin: 0 auto; height: 72px; } @@ -528,6 +529,29 @@ html { opacity: 0.85; } +/* Dashboard Navigation */ +.landing-nav__dashboard { + display: flex; + align-items: center; + gap: 3rem; + margin-left: auto; +} + +.landing-nav__dashboard-link { + color: rgba(240, 237, 237, 0.7); + text-decoration: none; + font-size: 0.9rem; + font-weight: 400; + letter-spacing: 0.08em; + text-transform: capitalize; + transition: color 0.3s ease; + white-space: nowrap; +} + +.landing-nav__dashboard-link:hover { + color: rgba(240, 237, 237, 1); +} + /* Nav links */ .landing-nav__links { display: flex; @@ -1134,7 +1158,6 @@ html { ═══════════════════════════════════════════════════════════════════ */ .app-sidebar { background: rgba(10, 10, 18, 0.95) !important; - border-right: 1px solid rgba(140, 120, 200, 0.08) !important; } .app-sidebar__new-chat { diff --git a/src/app/page.tsx b/src/app/page.tsx index f3e1e4e..d9e606f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -103,33 +103,22 @@ export default function Home() { TACK -