Files
maaz519 205418fc4e feat: isolate all four portals with route groups + polished design system
Fix blank /org/login (and /admin/login): the guarded portal layout was
wrapping its own login route, rendering null when unauthenticated. Move
each portal's authed pages into a (authed)/(chapter) route group so login
pages live outside the guard.

Structure:
- app/(chapter)/* — chapter admin pages + guarded layout (was root-level)
- app/org/(authed)/* — org pages + guarded layout; /org/login now free
- app/admin/(authed)/* — admin pages + guarded layout; /admin/login free
- Root layout slimmed to providers only (no shared sidebar)
- Convert moved files' relative imports to @/app alias

Design system:
- PortalShell: shared sidebar shell (brand mark, themed active nav with
  accent bar, avatar dropdown user menu, loading skeletons)
- portal-theme.ts: per-portal theme tokens (violet/slate/blue/emerald)
- PortalLoginShell redesigned: two-column with gradient branding panel,
  dotted pattern, value-prop highlights, built-in back link
- New shadcn components: Avatar, DropdownMenu, Skeleton
- Move shared DraftCard to _components/draft-card.tsx (used by 2 portals)
- Portal selector: remove Super Admin card, icon tiles, hover lift
- All 4 login pages use react-hook-form + Zod + themed shell
- Member nav restored to full 11-section list with icons

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-20 18:20:06 +05:30

108 lines
3.0 KiB
TypeScript

import Link from 'next/link';
import { apiFetch } from '@/app/_lib/api';
interface MeiliHit {
id: string;
content: string;
senderName: string;
sourceGroupName: string;
tags: string[];
approvedAt: number;
}
interface SearchResponse {
hits: MeiliHit[];
total: number;
page: number;
limit: number;
query: string;
}
export function SearchResults({
hits,
total,
q,
page,
}: {
hits: MeiliHit[];
total: number;
q: string;
page: number;
}) {
return (
<div className="flex flex-col gap-4">
<form method="GET" className="flex gap-2">
<input
name="q"
defaultValue={q}
placeholder="Search approved messages…"
className="flex-1 rounded-lg border border-gray-200 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400"
/>
<button
type="submit"
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
>
Search
</button>
</form>
{hits.length === 0 ? (
<p className="text-gray-500 text-sm">No results{q ? ` for "${q}"` : ''}.</p>
) : (
<>
<p className="text-sm text-gray-500">
{total} result{total !== 1 ? 's' : ''}
</p>
<ul className="flex flex-col gap-3">
{hits.map((hit) => (
<li key={hit.id}>
<Link
href={`/messages/${hit.id}`}
className="block rounded-xl border border-gray-200 bg-white p-4 hover:border-blue-300 hover:shadow-sm transition-colors"
>
<p className="text-sm">{hit.content}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-gray-400">
<span>{hit.senderName}</span>
<span>·</span>
<span>{hit.sourceGroupName}</span>
<span>·</span>
<span>{new Date(hit.approvedAt).toLocaleDateString()}</span>
{hit.tags.map((tag) => (
<span key={tag} className="rounded-full bg-blue-50 px-2 py-0.5 text-blue-600">
{tag}
</span>
))}
</div>
</Link>
</li>
))}
</ul>
</>
)}
</div>
);
}
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string; page?: string }>;
}) {
const { q = '', page = '1' } = await searchParams;
let data: SearchResponse = { hits: [], total: 0, page: 1, limit: 20, query: q };
try {
const res = await apiFetch(`/search?q=${encodeURIComponent(q)}&page=${page}`);
if (res.ok) data = await res.json();
} catch {
// API unavailable — render empty results
}
return (
<div className="max-w-2xl">
<h1 className="text-xl font-semibold mb-4">Search</h1>
<SearchResults hits={data.hits} total={data.total} q={q} page={Number(page)} />
</div>
);
}