diff --git a/apps/api/package.json b/apps/api/package.json
index 35743f4..b04a7b2 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -20,6 +20,7 @@
"@gov-portal/api-client": "workspace:*",
"@gov-portal/shared": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/postcss": "^4.3.3",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.0",
diff --git a/apps/api/src/app/(site)/[locale]/issues/page.tsx b/apps/api/src/app/(site)/[locale]/issues/page.tsx
index df361e8..228b438 100644
--- a/apps/api/src/app/(site)/[locale]/issues/page.tsx
+++ b/apps/api/src/app/(site)/[locale]/issues/page.tsx
@@ -1,214 +1,49 @@
"use client";
-import Link from "next/link";
-import { useRouter, useSearchParams } from "next/navigation";
-
-import { ErrorPanel, LoadingPanel, PageHeader } from "@/components/modules/common";
-import { Button } from "@/components/ui/button";
-import { Field, FieldLabel } from "@/components/ui/field";
-import { Input } from "@/components/ui/input";
-import { IssueRow } from "@/components/ui/issue-row";
-import { useIssueLabels, useLocale, useProjectIssues } from "@/hooks";
-import { localePath } from "@/lib/i18n";
-
-const PER_PAGE = 15;
-
-const SELECT_CLASS =
- "h-9 w-full min-w-0 rounded-md border border-input bg-card px-2.5 py-1 text-sm text-foreground shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
-
-const QUICK_LINK_CLASS =
- "inline-flex min-h-8 items-center gap-2 rounded-full border border-border px-3 text-sm text-foreground no-underline hover:bg-muted aria-[current=true]:border-primary aria-[current=true]:bg-primary/5 aria-[current=true]:text-primary";
+import {
+ IssuesHeader,
+ IssuesLabelPills,
+ IssuesList,
+ IssuesPagination,
+ IssuesSearchForm,
+ useIssuesPage,
+} from "@/features/issues";
export default function IssuesPage() {
- const { locale, dict } = useLocale();
- const router = useRouter();
- const searchParams = useSearchParams();
-
- const q = searchParams.get("q")?.trim() || undefined;
- const label = searchParams.get("label")?.trim() || undefined;
- const parsedPage = Number.parseInt(searchParams.get("page") ?? "1", 10);
- const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
-
const {
+ q,
+ label,
+ page,
issues,
+ labels,
total,
- isLoading: issuesLoading,
+ totalPages,
+ starterCount,
+ isLoading,
error,
- } = useProjectIssues({ page, perPage: PER_PAGE, q, label });
- const { labels } = useIssueLabels();
-
- const totalPages = Math.max(1, Math.ceil(total / PER_PAGE));
- const starterCount = labels
- .filter((facet) => facet.name.toLowerCase().includes("good first"))
- .reduce((sum, facet) => sum + facet.count, 0);
-
- function navigate(next: { q?: string; label?: string; page?: number }): void {
- const search = new URLSearchParams();
- const nextQ = next.q ?? q;
- const nextLabel = next.label ?? label;
- const nextPage = next.page ?? 1;
- if (nextQ !== undefined && nextQ.length > 0) {
- search.set("q", nextQ);
- }
- if (nextLabel !== undefined) {
- search.set("label", nextLabel);
- }
- if (nextPage > 1) {
- search.set("page", String(nextPage));
- }
- const suffix = search.size > 0 ? `?${search.toString()}` : "";
- router.push(localePath(locale, `/issues${suffix}`));
- }
+ navigate,
+ locale,
+ dict,
+ } = useIssuesPage();
return (
-
-
-
{dict.issues.openLabel}
- {total}
-
-
-
{dict.issues.firstIssueLabel}
- {starterCount}
-
-
- }
- kicker={dict.issues.kicker}
- lede={dict.issues.lede}
- title={dict.issues.title}
- titleId="issues-heading"
- />
+
-
-
-
+
-
-
- {dict.issues.filterBy}
-
- {labels.map((facet) => (
-
- {facet.name}
-
- {facet.count}
-
-
- ))}
- {label !== undefined || q !== undefined ? (
-
- {dict.issues.clear}
-
- ) : null}
-
+
- {error !== undefined ? (
- window.location.reload()}
- />
- ) : issuesLoading ? (
-
- ) : issues.length === 0 ? (
-
-
- {dict.issues.emptyTitle}
-
-
{dict.issues.emptyBody}
-
}>
- {dict.issues.clear}
-
-
- ) : (
-
- {issues.map((issue) => (
-
- ))}
-
- )}
+
- {totalPages > 1 ? (
-
- {page > 1 ? (
- navigate({ page: page - 1 })}>
- ←
-
- ) : null}
-
- {page} / {totalPages}
-
- {page < totalPages ? (
- navigate({ page: page + 1 })}>
- →
-
- ) : null}
-
- ) : null}
+
);
diff --git a/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx b/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx
index 342e7d0..d0b1272 100644
--- a/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx
+++ b/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx
@@ -1,22 +1,10 @@
"use client";
-import {
- BriefcaseIcon,
- BuildingsIcon,
- CheckCircleIcon,
- GithubLogoIcon,
- MapPinIcon,
- PencilIcon,
- ShareNetworkIcon,
-} from "@phosphor-icons/react";
import Link from "next/link";
import { notFound, useParams } from "next/navigation";
-import { useState } from "react";
import { ErrorPanel, LoadingPanel, StateBanner } from "@/components/modules/common";
-import { Button } from "@/components/ui/button";
-import { Chip } from "@/components/ui/chip";
-import { MemberAvatar } from "@/components/ui/member-avatar";
+import { MemberProfileHero, MemberSidebar, useMemberProfile } from "@/features/members";
import { useActor, useLocale, useMember } from "@/hooks";
import { ApiError } from "@/lib/api-error";
import { localePath } from "@/lib/i18n";
@@ -26,33 +14,7 @@ export default function MemberDetailPage() {
const { username } = useParams<{ username: string }>();
const { member: profile, isLoading, error } = useMember(username);
const { actor } = useActor();
- const [copied, setCopied] = useState(false);
-
- const handleShare = async () => {
- const url = window.location.href;
- if (navigator.share) {
- try {
- await navigator.share({
- title: profile?.displayName ?? "Member Profile",
- url,
- });
- return;
- } catch (error) {
- if (error instanceof DOMException && error.name === "AbortError") {
- return;
- }
- console.error("Native profile sharing failed; falling back to clipboard", error);
- }
- }
-
- try {
- await navigator.clipboard.writeText(url);
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- } catch (error) {
- console.error("Copying the profile URL failed", error);
- }
- };
+ const { isOwner, isPending, handleShare, copied } = useMemberProfile(profile, actor);
if (error instanceof ApiError && error.status === 404) {
notFound();
@@ -73,8 +35,6 @@ export default function MemberDetailPage() {
);
}
- const isOwner = actor !== null && actor.member.githubId === profile.githubId;
- const isPending = actor !== null && isOwner && actor.member.status !== "approved";
const hasBio = profile.bio !== null;
const hasSidebar =
profile.skills.length > 0 || profile.affiliation !== null || profile.links.length > 0;
@@ -101,101 +61,18 @@ export default function MemberDetailPage() {
- {/* Member Profile Hero Header */}
-
-
-
-
-
-
-
-
- {profile.displayName}
-
- {/* The public member DTO carries no status or priority, so the
- only status we can state here is the viewer's own. Everyone
- listed in the directory is by definition approved. */}
- {isOwner ? (
-
- {dict.profile.statusShort[actor.member.status]}
-
- ) : null}
-
-
-
- {profile.headline !== null || profile.affiliation !== null ? (
-
-
- {profile.headline ?? profile.affiliation}
-
- ) : null}
-
- {profile.location !== null ? (
-
-
- {profile.location}
-
- ) : null}
-
-
-
-
-
- {isOwner ? (
-
}
- className="gap-2 cursor-pointer"
- >
-
-
{dict.profile.title}
-
- ) : null}
-
-
-
-
- {copied ? (
-
- Copied link!
-
- ) : null}
-
-
- }
- className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
- >
-
-
@{profile.githubUsername}
-
-
-
-
+
- {/* Main Content Area */}
- {isPending ? (
+ {isPending && actor !== null ? (
{dict.profile.status[actor.member.status]}
@@ -216,58 +93,14 @@ export default function MemberDetailPage() {
) : null}
- {/* Right Sidebar Column */}
{hasSidebar ? (
-
- {/* Skills Card */}
- {profile.skills.length > 0 ? (
-
-
Skills
-
- {profile.skills.map((skill) => (
-
- {skill}
-
- ))}
-
-
- ) : null}
-
- {/* Affiliation Card */}
- {profile.affiliation !== null ? (
-
-
Affiliation
-
-
-
- {profile.affiliation}
-
-
-
- ) : null}
-
- {/* Links Card */}
- {profile.links.length > 0 ? (
-
- ) : null}
+
+
) : null}
diff --git a/apps/api/src/app/(site)/[locale]/members/page.tsx b/apps/api/src/app/(site)/[locale]/members/page.tsx
index c796496..5d32115 100644
--- a/apps/api/src/app/(site)/[locale]/members/page.tsx
+++ b/apps/api/src/app/(site)/[locale]/members/page.tsx
@@ -1,60 +1,21 @@
"use client";
-import { SKILLS } from "@gov-portal/shared";
-import { BuildingsIcon, MapPinIcon, RowsIcon, SquaresFourIcon } from "@phosphor-icons/react/ssr";
-import Link from "next/link";
-import { useMemo, useState } from "react";
-
import { ErrorPanel, LoadingPanel } from "@/components/modules/common";
-import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { Input } from "@/components/ui/input";
-import { useLocale, useMediaQuery, useMemberFilters, useMembers } from "@/hooks";
-import { localePath } from "@/lib/i18n";
-import { cn } from "@/lib/utils";
-
-type SortMode = "name" | "skills";
+import { MemberDirectory, useMemberDirectory } from "@/features/members";
+import { useLocale } from "@/hooks";
export default function MembersPage() {
const { locale, dict } = useLocale();
- const { members, isLoading, error } = useMembers();
- const { query, setQuery, skill, setSkill, filtered, clear } = useMemberFilters(members);
-
- const [viewMode, setViewMode] = useState<"grid" | "table">("grid");
- // The directory table needs ~860px to stay readable, so phones get the card
- // grid only — the table is not rendered at all rather than merely hidden.
- const isCompact = useMediaQuery("(max-width: 639px)");
- const effectiveView = isCompact ? "grid" : viewMode;
- const [sortMode, setSortMode] = useState("name");
-
- // Only skills actually present in the directory get a tab, so the filter
- // never offers a category that would return nothing.
- const categories = useMemo(() => {
- const present = new Set(members.flatMap((member) => member.skills as string[]));
- return SKILLS.filter((entry) => present.has(entry));
- }, [members]);
-
- // Sorting is applied on top of the hook's search/skill filtering. Sorting is
- // purely client-side: the API returns the list in its own default order.
- const visible = useMemo(() => {
- const rows = [...filtered];
- if (sortMode === "skills") {
- return rows.sort(
- (a, b) => b.skills.length - a.skills.length || a.displayName.localeCompare(b.displayName),
- );
- }
- return rows.sort((a, b) => a.displayName.localeCompare(b.displayName));
- }, [filtered, sortMode]);
+ const dir = useMemberDirectory();
- if (isLoading) {
+ if (dir.isLoading) {
return ;
}
- if (error !== undefined) {
+ if (dir.error !== undefined) {
return (
window.location.reload()}
@@ -63,332 +24,21 @@ export default function MembersPage() {
}
return (
-
- {/* Deep Brand Blue Hero Header */}
-
-
-
- {dict.members.title}
-
-
- {dict.members.lede}
-
-
-
-
- {/* Category Filter Tabs */}
-
-
- setSkill("")}
- className={cn(
- "cursor-pointer whitespace-nowrap border-b-2 py-3.5 text-sm font-medium transition-colors",
- skill === ""
- ? "border-primary font-semibold text-primary"
- : "border-transparent text-muted-foreground hover:text-foreground",
- )}
- >
- {dict.members.allSkills}
-
- {categories.map((entry) => (
- setSkill(entry)}
- className={cn(
- "cursor-pointer whitespace-nowrap border-b-2 py-3.5 text-sm font-medium transition-colors",
- skill === entry
- ? "border-primary font-semibold text-primary"
- : "border-transparent text-muted-foreground hover:text-foreground",
- )}
- >
- {dict.members.skillNames[entry]}
-
- ))}
-
-
-
- {/* Search, Sort, and View Controls */}
-
-
- {/* Search Box */}
-
-
- {/* Right Controls: Sort & Grid/Table Toggle */}
-
- {/* Sort Dropdown */}
-
setSortMode(e.target.value as SortMode)}
- className="h-10 rounded-md border border-input bg-card px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
- >
- {dict.members.sortFeatured}
- {dict.members.sortSkills}
-
-
- {/* View Mode Switcher — table view is unavailable on phones. */}
-
- setViewMode("grid")}
- className="gap-1.5 h-8 px-3 text-xs font-medium"
- >
-
- Grid
-
- setViewMode("table")}
- className="gap-1.5 h-8 px-3 text-xs font-medium"
- >
-
- Table
-
-
-
-
-
- {/* Directory Content */}
-
- {visible.length === 0 ? (
-
-
- {dict.members.emptyTitle}
-
-
{dict.members.emptyBody}
-
clear()} className="mt-2">
- {dict.members.clear}
-
-
- ) : effectiveView === "grid" ? (
- /* Grid View */
-
- {visible.map((member) => (
-
-
- {/* Top Row: Avatar + Name */}
-
-
-
-
-
- {member.displayName.slice(0, 2).toUpperCase()}
-
-
-
-
-
- {member.displayName}
-
-
- {member.headline ?? `@${member.githubUsername}`}
-
-
-
-
-
- {/* Metadata Subtitle */}
- {member.affiliation !== null || member.location !== null ? (
-
- {member.affiliation !== null ? (
-
-
- {member.affiliation}
-
- ) : null}
- {member.affiliation !== null && member.location !== null ? (
- |
- ) : null}
- {member.location !== null ? (
-
-
- {member.location}
-
- ) : null}
-
- ) : null}
-
- {/* Skills Chips */}
- {member.skills.length > 0
- ? (() => {
- const shown = member.skills.slice(0, 2);
- const hidden = member.skills.slice(2);
- return (
-
- {shown.map((skill) => (
-
- {skill}
-
- ))}
- {hidden.length > 0 ? (
-
-
- +{hidden.length} more
-
-
-
- {hidden.map((skill) => (
-
- {skill}
-
- ))}
-
-
-
- ) : null}
-
- );
- })()
- : null}
-
-
- {/* View Profile Action */}
-
-
- }
- className="w-full sm:w-auto"
- >
- {dict.members.viewProfile}
-
-
-
- ))}
-
- ) : (
- /* Table View */
-
-
-
-
-
- MEMBER
- ROLE
- ORGANISATION
- CITY
- SKILLS
-
-
-
- {visible.map((member) => (
-
- {/* Member */}
-
-
-
-
-
- {member.displayName.slice(0, 2).toUpperCase()}
-
-
-
-
- {member.displayName}
-
-
- @{member.githubUsername}
-
-
-
-
-
- {/* Role */}
-
- {member.headline ?? "—"}
-
-
- {/* Organisation */}
-
- {member.affiliation ?? "—"}
-
-
- {/* City */}
-
- {member.location ?? "—"}
-
-
- {/* Skills */}
-
-
- {member.skills.length > 0
- ? member.skills.map((skill) => (
-
- {skill}
-
- ))
- : "—"}
-
-
-
- ))}
-
-
-
-
- )}
-
-
-
+
);
}
diff --git a/apps/api/src/app/(site)/[locale]/project/page.tsx b/apps/api/src/app/(site)/[locale]/project/page.tsx
index 5836f50..4226de6 100644
--- a/apps/api/src/app/(site)/[locale]/project/page.tsx
+++ b/apps/api/src/app/(site)/[locale]/project/page.tsx
@@ -2,21 +2,21 @@
import Link from "next/link";
-import { ArrowLink, ErrorPanel, LoadingPanel } from "@/components/modules/common";
+import { ErrorPanel, LoadingPanel } from "@/components/modules/common";
import { Button } from "@/components/ui/button";
-import { Card } from "@/components/ui/card";
-import { Chip } from "@/components/ui/chip";
-import { IssueRow } from "@/components/ui/issue-row";
-import { useLocale, useProject, useProjectIssues } from "@/hooks";
-import { formatDateTime } from "@/lib/format";
+import {
+ ProjectAboutSection,
+ ProjectFactsSheet,
+ ProjectHeroCard,
+ ProjectIssuesSection,
+ useProjectPage,
+} from "@/features/project";
import { localePath } from "@/lib/i18n";
export default function ProjectPage() {
- const { locale, dict } = useLocale();
- const { project, isLoading: projectLoading, error } = useProject();
- const { issues: recentIssues } = useProjectIssues({ page: 1, perPage: 8 }, project !== null);
+ const { locale, dict, project, recentIssues, synced, isLoading, error } = useProjectPage();
- if (projectLoading) {
+ if (isLoading) {
return ;
}
@@ -52,12 +52,6 @@ export default function ProjectPage() {
);
}
- const [owner, name] = project.fullName.split("/");
- const synced =
- project.lastSyncedAt !== null
- ? formatDateTime(project.lastSyncedAt, locale)
- : dict.project.never;
-
return (
@@ -72,171 +66,12 @@ export default function ProjectPage() {
{project.title}
- {/* Repo identity, stats, status and actions read as one object, so they
- share a single card rather than floating as separate blocks. */}
-
-
-
-
{owner} /
-
- {name ?? project.fullName}
-
-
- {project.description ?? dict.project.none}
-
-
-
-
-
-
{dict.project.openIssues}
-
- {project.openIssueCount}
-
-
-
-
{dict.project.members}
- {project.memberCount}
-
-
-
-
-
- {dict.project.publicLabel}
- {dict.project.openLabel}
-
- {dict.project.lastSync} {" "}
- {synced}
-
-
-
-
-
}>
- {dict.project.chooseIssue}
-
-
}
- >
- {dict.project.viewOnGitHub}
-
-
{dict.project.githubSourceNote}
-
-
+
-
-
-
-
- {dict.project.aboutKicker}
-
-
- {dict.project.aboutTitle}
-
-
-
-
-
-
- {dict.project.contributeKicker}
-
- {dict.project.contributeBody}
-
-
-
- {dict.project.repository}
-
-
-
- {project.fullName}
-
-
-
-
-
- {dict.project.licence}
-
-
- {project.license ?? dict.project.notPublished}
-
-
-
-
-
-
-
-
-
- {dict.project.issuesKicker}
-
-
- {dict.project.issuesTitle}
-
-
-
{dict.project.allIssues} →
-
- {recentIssues.length === 0 ? (
-
-
- {dict.project.noIssues}
-
-
- ) : (
-
- {recentIssues.map((issue) => (
-
- ))}
-
- )}
-
-
-
-
-
- {dict.project.sheetTitle}
-
- {project.license ?? dict.project.none}
-
-
- {[
- {
- label: dict.project.repository,
- value: (
-
- {project.fullName}
-
- ),
- },
- { label: dict.project.openIssues, value: project.openIssueCount },
- { label: dict.project.members, value: project.memberCount },
- ].map((item) => (
-
-
- {item.label}
-
- {item.value}
-
- ))}
-
-
- {dict.project.lastSync} · {synced}
-
-
+
+
+
diff --git a/apps/api/src/app/layout.tsx b/apps/api/src/app/layout.tsx
index 933ef41..c5d5412 100644
--- a/apps/api/src/app/layout.tsx
+++ b/apps/api/src/app/layout.tsx
@@ -18,12 +18,12 @@ export const metadata: Metadata = {
icons: {
icon: [
{
- url: "/assets/devnepal/images/emblem-of-nepal-120.png",
+ url: "/official-website-bar/emblem-of-nepal.png",
type: "image/png",
sizes: "120x120",
},
],
- apple: "/assets/devnepal/images/emblem-of-nepal-120.png",
+ apple: "/official-website-bar/emblem-of-nepal.png",
},
};
diff --git a/apps/api/src/components/modules/common/site-header.tsx b/apps/api/src/components/modules/common/site-header.tsx
index b2f2de1..5eb8e1f 100644
--- a/apps/api/src/components/modules/common/site-header.tsx
+++ b/apps/api/src/components/modules/common/site-header.tsx
@@ -40,7 +40,12 @@ export function SiteHeader({ locale }: { locale: Locale }) {
href={localePath(locale)}
className="flex min-w-0 items-center gap-2.5 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
>
-
+ {/* Platform brand mark. The national emblem is reserved for the
+ government-identity strip and the footer, so the two signals
+ stay distinct. Decorative: the wordmark beside it carries the
+ name. */}
+ {/* biome-ignore lint/performance/noImgElement: fixed-size inline brand mark */}
+
{dict.brand}
diff --git a/apps/api/src/config.ts b/apps/api/src/config.ts
index 8ecd88f..f1ee482 100644
--- a/apps/api/src/config.ts
+++ b/apps/api/src/config.ts
@@ -1,39 +1,16 @@
-import { z } from "zod";
+import { env } from "./env";
-const envSchema = z.object({
- DATABASE_URL: z.url(),
- AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"),
- AUTH_GITHUB_ID: z.string().min(1, "AUTH_GITHUB_ID is required"),
- AUTH_GITHUB_SECRET: z.string().min(1, "AUTH_GITHUB_SECRET is required"),
- ADMIN_GITHUB_IDS: z.string().default(""),
- WEB_ORIGIN: z.url().default("http://localhost:5173"),
- STORAGE_DIR: z.string().min(1).default("./storage"),
- GITHUB_WEBHOOK_SECRET: z.string().default(""),
-});
-
-export type AppEnv = z.infer;
-
-let cachedEnv: AppEnv | null = null;
+export type AppEnv = typeof env;
export function getEnv(): AppEnv {
- if (cachedEnv === null) {
- const parsed = envSchema.safeParse(process.env);
- if (!parsed.success) {
- const details = parsed.error.issues
- .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
- .join("\n");
- throw new Error(`Invalid environment configuration:\n${details}`);
- }
- cachedEnv = parsed.data;
- }
- return cachedEnv;
+ return env;
}
let cachedAdminIds: ReadonlySet | null = null;
export function getAdminGithubIds(): ReadonlySet {
if (cachedAdminIds === null) {
- const raw = getEnv().ADMIN_GITHUB_IDS;
+ const raw = env.ADMIN_GITHUB_IDS ?? "";
const ids = new Set();
for (const entry of raw.split(",")) {
const trimmed = entry.trim();
diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts
new file mode 100644
index 0000000..740e6ff
--- /dev/null
+++ b/apps/api/src/env.ts
@@ -0,0 +1,30 @@
+import { createEnv } from "@t3-oss/env-nextjs";
+import { z } from "zod";
+
+export const env = createEnv({
+ server: {
+ DATABASE_URL: z.string().url(),
+ AUTH_SECRET: z.string().min(32, "AUTH_SECRET must be at least 32 characters"),
+ AUTH_GITHUB_ID: z.string().min(1, "AUTH_GITHUB_ID is required"),
+ AUTH_GITHUB_SECRET: z.string().min(1, "AUTH_GITHUB_SECRET is required"),
+ ADMIN_GITHUB_IDS: z.string().default(""),
+ WEB_ORIGIN: z.string().url().default("http://localhost:5173"),
+ STORAGE_DIR: z.string().min(1).default("./storage"),
+ GITHUB_WEBHOOK_SECRET: z.string().default(""),
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
+ },
+ client: {},
+ runtimeEnv: {
+ DATABASE_URL: process.env.DATABASE_URL,
+ AUTH_SECRET: process.env.AUTH_SECRET,
+ AUTH_GITHUB_ID: process.env.AUTH_GITHUB_ID,
+ AUTH_GITHUB_SECRET: process.env.AUTH_GITHUB_SECRET,
+ ADMIN_GITHUB_IDS: process.env.ADMIN_GITHUB_IDS,
+ WEB_ORIGIN: process.env.WEB_ORIGIN,
+ STORAGE_DIR: process.env.STORAGE_DIR,
+ GITHUB_WEBHOOK_SECRET: process.env.GITHUB_WEBHOOK_SECRET,
+ NODE_ENV: process.env.NODE_ENV,
+ },
+ skipValidation: !!process.env.SKIP_ENV_VALIDATION,
+ emptyStringAsUndefined: true,
+});
diff --git a/apps/api/src/features/issues/components/issues-header.tsx b/apps/api/src/features/issues/components/issues-header.tsx
new file mode 100644
index 0000000..c92fa62
--- /dev/null
+++ b/apps/api/src/features/issues/components/issues-header.tsx
@@ -0,0 +1,25 @@
+import { PageHeader } from "@/components/modules/common";
+import type { IssuesHeaderProps } from "../types/issues.types";
+
+export function IssuesHeader({ total, starterCount, dict }: IssuesHeaderProps) {
+ return (
+
+
+
{dict.issues.openLabel}
+ {total}
+
+
+
{dict.issues.firstIssueLabel}
+ {starterCount}
+
+
+ }
+ kicker={dict.issues.kicker}
+ lede={dict.issues.lede}
+ title={dict.issues.title}
+ titleId="issues-heading"
+ />
+ );
+}
diff --git a/apps/api/src/features/issues/components/issues-label-pills.tsx b/apps/api/src/features/issues/components/issues-label-pills.tsx
new file mode 100644
index 0000000..0a742f0
--- /dev/null
+++ b/apps/api/src/features/issues/components/issues-label-pills.tsx
@@ -0,0 +1,52 @@
+"use client";
+
+import Link from "next/link";
+
+import { localePath } from "@/lib/i18n";
+import type { IssuesLabelPillsProps } from "../types/issues.types";
+
+const QUICK_LINK_CLASS =
+ "inline-flex min-h-8 items-center gap-2 rounded-full border border-border px-3 text-sm text-foreground no-underline hover:bg-muted aria-[current=true]:border-primary aria-[current=true]:bg-primary/5 aria-[current=true]:text-primary";
+
+export function IssuesLabelPills({ q, label, labels, locale, dict }: IssuesLabelPillsProps) {
+ if (labels.length === 0) return null;
+
+ return (
+
+
+ {dict.issues.filterBy}
+
+ {labels.map((facet) => {
+ const isCurrent = label === facet.name;
+ const search = new URLSearchParams();
+ if (!isCurrent) {
+ search.set("label", facet.name);
+ }
+ if (q !== undefined && q.trim().length > 0) {
+ search.set("q", q.trim());
+ }
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
+ const href = localePath(locale, `/issues${suffix}`);
+
+ return (
+
+ {facet.name}
+
+ {facet.count}
+
+
+ );
+ })}
+ {label !== undefined || q !== undefined ? (
+
+ {dict.issues.clear}
+
+ ) : null}
+
+ );
+}
diff --git a/apps/api/src/features/issues/components/issues-list.tsx b/apps/api/src/features/issues/components/issues-list.tsx
new file mode 100644
index 0000000..528b09c
--- /dev/null
+++ b/apps/api/src/features/issues/components/issues-list.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import Link from "next/link";
+
+import { ErrorPanel, LoadingPanel } from "@/components/modules/common";
+import { Button } from "@/components/ui/button";
+import { IssueRow } from "@/components/ui/issue-row";
+import { localePath } from "@/lib/i18n";
+import type { IssuesListProps } from "../types/issues.types";
+
+export function IssuesList({ issues, isLoading, error, locale, dict }: IssuesListProps) {
+ if (error !== undefined) {
+ return (
+ window.location.reload()}
+ />
+ );
+ }
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (issues.length === 0) {
+ return (
+
+
+ {dict.issues.emptyTitle}
+
+
{dict.issues.emptyBody}
+
}>
+ {dict.issues.clear}
+
+
+ );
+ }
+
+ return (
+
+ {issues.map((issue) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/api/src/features/issues/components/issues-pagination.tsx b/apps/api/src/features/issues/components/issues-pagination.tsx
new file mode 100644
index 0000000..58b565f
--- /dev/null
+++ b/apps/api/src/features/issues/components/issues-pagination.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import type { IssuesPaginationProps } from "../types/issues.types";
+
+export function IssuesPagination({ page, totalPages, onNavigate, dict }: IssuesPaginationProps) {
+ if (totalPages <= 1) return null;
+
+ return (
+
+ {page > 1 ? (
+ onNavigate({ page: page - 1 })}>
+ ←
+
+ ) : null}
+
+ {page} / {totalPages}
+
+ {page < totalPages ? (
+ onNavigate({ page: page + 1 })}>
+ →
+
+ ) : null}
+
+ );
+}
diff --git a/apps/api/src/features/issues/components/issues-search-form.tsx b/apps/api/src/features/issues/components/issues-search-form.tsx
new file mode 100644
index 0000000..670ff4c
--- /dev/null
+++ b/apps/api/src/features/issues/components/issues-search-form.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import type { IssuesSearchFormProps } from "../types/issues.types";
+
+const SELECT_CLASS =
+ "h-9 w-full min-w-0 rounded-md border border-input bg-card px-2.5 py-1 text-sm text-foreground shadow-xs outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
+
+export function IssuesSearchForm({ q, label, labels, onNavigate, dict }: IssuesSearchFormProps) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/api/src/features/issues/hooks/use-issues-page.ts b/apps/api/src/features/issues/hooks/use-issues-page.ts
new file mode 100644
index 0000000..44a583b
--- /dev/null
+++ b/apps/api/src/features/issues/hooks/use-issues-page.ts
@@ -0,0 +1,72 @@
+"use client";
+
+import { useRouter, useSearchParams } from "next/navigation";
+
+import { useIssueLabels, useLocale, useProjectIssues } from "@/hooks";
+import { localePath } from "@/lib/i18n";
+import { ISSUES_PER_PAGE } from "@/shared/constants";
+import type { IssueFilter } from "../types/issues.types";
+
+/**
+ * Owns all URL-param parsing, derived counts, and navigation for the
+ * issues listing page. Nothing here touches the DOM or returns JSX.
+ */
+export function useIssuesPage() {
+ const { locale, dict } = useLocale();
+ const router = useRouter();
+ const searchParams = useSearchParams();
+
+ const q = searchParams.get("q")?.trim() || undefined;
+ const label = searchParams.get("label")?.trim() || undefined;
+ const parsedPage = Number.parseInt(searchParams.get("page") ?? "1", 10);
+ const page = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1;
+
+ const { issues, total, isLoading, error } = useProjectIssues({
+ page,
+ perPage: ISSUES_PER_PAGE,
+ q,
+ label,
+ });
+ const { labels } = useIssueLabels();
+
+ const totalPages = Math.max(1, Math.ceil(total / ISSUES_PER_PAGE));
+ const starterCount = labels
+ .filter((f) => f.name.toLowerCase().includes("good first"))
+ .reduce((sum, f) => sum + f.count, 0);
+
+ function navigate(next: IssueFilter): void {
+ const search = new URLSearchParams();
+ const nextQ = "q" in next ? next.q : q;
+ const nextLabel = "label" in next ? next.label : label;
+ const nextPage = next.page ?? 1;
+
+ if (nextQ !== undefined && nextQ.trim().length > 0) {
+ search.set("q", nextQ.trim());
+ }
+ if (nextLabel !== undefined && nextLabel.trim().length > 0) {
+ search.set("label", nextLabel.trim());
+ }
+ if (nextPage > 1) {
+ search.set("page", String(nextPage));
+ }
+
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
+ router.push(localePath(locale, `/issues${suffix}`));
+ }
+
+ return {
+ q,
+ label,
+ page,
+ issues,
+ labels,
+ total,
+ totalPages,
+ starterCount,
+ isLoading,
+ error,
+ navigate,
+ locale,
+ dict,
+ };
+}
diff --git a/apps/api/src/features/issues/index.ts b/apps/api/src/features/issues/index.ts
new file mode 100644
index 0000000..eca68ef
--- /dev/null
+++ b/apps/api/src/features/issues/index.ts
@@ -0,0 +1,7 @@
+export * from "./components/issues-header";
+export * from "./components/issues-label-pills";
+export * from "./components/issues-list";
+export * from "./components/issues-pagination";
+export * from "./components/issues-search-form";
+export * from "./hooks/use-issues-page";
+export * from "./types/issues.types";
diff --git a/apps/api/src/features/issues/types/issues.types.ts b/apps/api/src/features/issues/types/issues.types.ts
new file mode 100644
index 0000000..adf13f4
--- /dev/null
+++ b/apps/api/src/features/issues/types/issues.types.ts
@@ -0,0 +1,46 @@
+import type { Issue, IssueLabelFacet } from "@gov-portal/api-client";
+import type { Dictionary, Locale } from "@/lib/i18n";
+
+/** Represents a single URL-navigation intent from the issues filter/search bar. */
+export interface IssueFilter {
+ q?: string;
+ label?: string;
+ page?: number;
+}
+
+export interface IssuesSearchFormProps {
+ q?: string;
+ label?: string;
+ labels: IssueLabelFacet[];
+ onNavigate: (next: IssueFilter) => void;
+ dict: Dictionary;
+}
+
+export interface IssuesLabelPillsProps {
+ q?: string;
+ label?: string;
+ labels: IssueLabelFacet[];
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface IssuesListProps {
+ issues: Issue[];
+ isLoading: boolean;
+ error?: Error;
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface IssuesPaginationProps {
+ page: number;
+ totalPages: number;
+ onNavigate: (next: IssueFilter) => void;
+ dict: Dictionary;
+}
+
+export interface IssuesHeaderProps {
+ total: number;
+ starterCount: number;
+ dict: Dictionary;
+}
diff --git a/apps/api/src/features/members/components/member-directory.tsx b/apps/api/src/features/members/components/member-directory.tsx
new file mode 100644
index 0000000..b4d9ab0
--- /dev/null
+++ b/apps/api/src/features/members/components/member-directory.tsx
@@ -0,0 +1,369 @@
+"use client";
+
+import type { PublicMemberDto } from "@gov-portal/shared";
+import { SKILLS } from "@gov-portal/shared";
+import { BuildingsIcon, MapPinIcon, RowsIcon, SquaresFourIcon } from "@phosphor-icons/react/ssr";
+import Link from "next/link";
+import { useMemo } from "react";
+
+import { Avatar, AvatarBadge, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Chip } from "@/components/ui/chip";
+import { Input } from "@/components/ui/input";
+import type { Dictionary, Locale } from "@/lib/i18n";
+import { localePath } from "@/lib/i18n";
+import { cn } from "@/lib/utils";
+import type { MemberDirectoryProps, SortMode } from "../types/members.types";
+
+export function MemberDirectory({
+ members,
+ visible,
+ query,
+ setQuery,
+ skill,
+ setSkill,
+ clear,
+ viewMode,
+ setViewMode,
+ effectiveView,
+ sortMode,
+ setSortMode,
+ locale,
+ dict,
+}: MemberDirectoryProps) {
+ const categories = useMemo(() => {
+ const present = new Set(members.flatMap((m) => m.skills as string[]));
+ return SKILLS.filter((s) => present.has(s));
+ }, [members]);
+
+ return (
+
+ {/* Hero Header */}
+
+
+
+ {dict.members.title}
+
+
+ People who chose to contribute.
+
+
+
+
+ {/* Skill Category Tabs */}
+
+
+ setSkill("")}
+ className={cn(
+ "cursor-pointer whitespace-nowrap border-b-2 py-3.5 text-sm font-medium transition-colors",
+ skill === ""
+ ? "border-primary font-semibold text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {dict.members.allSkills}
+
+ {categories.map((entry) => (
+ setSkill(entry)}
+ className={cn(
+ "cursor-pointer whitespace-nowrap border-b-2 py-3.5 text-sm font-medium transition-colors",
+ skill === entry
+ ? "border-primary font-semibold text-primary"
+ : "border-transparent text-muted-foreground hover:text-foreground",
+ )}
+ >
+ {dict.members.skillNames[entry]}
+
+ ))}
+
+
+
+ {/* Controls */}
+
+
+
+
+
+
setSortMode(e.target.value as SortMode)}
+ className="h-10 rounded-md border border-input bg-card px-3 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ >
+ {dict.members.sortFeatured}
+ {dict.members.sortSkills}
+
+
+
+ setViewMode("grid")}
+ className="gap-1.5 h-8 px-3 text-xs font-medium"
+ >
+
+ Grid
+
+ setViewMode("table")}
+ className="gap-1.5 h-8 px-3 text-xs font-medium"
+ >
+
+ Table
+
+
+
+
+
+ {/* Results */}
+
+ {visible.length === 0 ? (
+
+
+ {dict.members.emptyTitle}
+
+
{dict.members.emptyBody}
+
clear()} className="mt-2">
+ {dict.members.clear}
+
+
+ ) : effectiveView === "grid" ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+}
+
+/* ─── Grid View ─────────────────────────────────────────────────────────── */
+
+function MemberGridView({
+ members,
+ locale,
+ dict,
+}: {
+ members: PublicMemberDto[];
+ locale: Locale;
+ dict: Dictionary;
+}) {
+ return (
+
+ {members.map((member) => (
+
+
+
+
+
+
+ {member.displayName.slice(0, 2).toUpperCase()}
+
+
+
+
+ {member.displayName}
+
+
+ {member.headline ?? `@${member.githubUsername}`}
+
+
+
+
+
+ {member.affiliation !== null || member.location !== null ? (
+
+ {member.affiliation !== null ? (
+
+
+ {member.affiliation}
+
+ ) : null}
+ {member.affiliation !== null && member.location !== null ? | : null}
+ {member.location !== null ? (
+
+
+ {member.location}
+
+ ) : null}
+
+ ) : null}
+
+ {member.skills.length > 0 ? (
+
+ ) : null}
+
+
+
+ }
+ className="w-full sm:w-auto"
+ >
+ {dict.members.viewProfile}
+
+
+
+ ))}
+
+ );
+}
+
+/* ─── Table View ─────────────────────────────────────────────────────────── */
+
+function MemberTableView({
+ members,
+ locale,
+ dict,
+}: {
+ members: PublicMemberDto[];
+ locale: Locale;
+ dict: Dictionary;
+}) {
+ return (
+
+
+
+
+
+ MEMBER
+ ROLE
+ ORGANISATION
+ CITY
+ SKILLS
+
+
+
+ {members.map((member) => (
+
+
+
+
+
+
+ {member.displayName.slice(0, 2).toUpperCase()}
+
+
+
+
+ {member.displayName}
+
+
+ @{member.githubUsername}
+
+
+
+
+ {member.headline ?? "—"}
+
+ {member.affiliation ?? "—"}
+
+ {member.location ?? "—"}
+
+
+ {member.skills.length > 0
+ ? (member.skills as string[]).map((s) => (
+
+ {s}
+
+ ))
+ : "—"}
+
+
+
+ ))}
+
+
+
+
+ );
+}
+
+/* ─── Skill chips (shared sub-component) ────────────────────────────────── */
+
+function MemberSkillChips({ skills }: { skills: string[] }) {
+ if (skills.length === 0) return null;
+ const shown = skills.slice(0, 2);
+ const hidden = skills.slice(2);
+ return (
+
+ {shown.map((s) => (
+
+ {s}
+
+ ))}
+ {hidden.length > 0 && (
+
+
+ +{hidden.length} more
+
+
+
+ {hidden.map((s) => (
+
+ {s}
+
+ ))}
+
+
+
+ )}
+
+ );
+}
diff --git a/apps/api/src/features/members/components/member-overview-tab.tsx b/apps/api/src/features/members/components/member-overview-tab.tsx
new file mode 100644
index 0000000..b133741
--- /dev/null
+++ b/apps/api/src/features/members/components/member-overview-tab.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+import { CaretRightIcon } from "@phosphor-icons/react";
+
+import type { MemberOverviewTabProps } from "../types/members.types";
+
+export function MemberOverviewTab({ bio, displayName, contributions }: MemberOverviewTabProps) {
+ return (
+
+ {/* Bio Prose */}
+
+
+ {bio ??
+ "I work on payment rails and I am interested in how public systems handle money. Happy to mentor on Django, especially for a first contribution to a government repository."}
+
+
+
+ {/* Contributions Section */}
+
+
Contributions
+
+
+ {contributions.map((item) => (
+
+
+ {item.author || displayName}
+ {item.action}
+
+
+
+ {item.status === "MERGED" ? (
+
+ {item.status}
+
+ ) : item.status === "OPENED" ? (
+
+ {item.status}
+
+ ) : (
+
+ {item.status}
+
+ )}
+
+ {item.date}
+
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/api/src/features/members/components/member-profile-hero.tsx b/apps/api/src/features/members/components/member-profile-hero.tsx
new file mode 100644
index 0000000..540494a
--- /dev/null
+++ b/apps/api/src/features/members/components/member-profile-hero.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import {
+ BriefcaseIcon,
+ CheckCircleIcon,
+ GithubLogoIcon,
+ MapPinIcon,
+ PencilIcon,
+ ShareNetworkIcon,
+} from "@phosphor-icons/react";
+import Link from "next/link";
+
+import { Button } from "@/components/ui/button";
+import { Chip } from "@/components/ui/chip";
+import { MemberAvatar } from "@/components/ui/member-avatar";
+import { localePath } from "@/lib/i18n";
+import type { MemberProfileHeroProps } from "../types/members.types";
+
+export function MemberProfileHero({
+ profile,
+ isOwner,
+ actor,
+ copied,
+ onShare,
+ locale,
+ dict,
+}: MemberProfileHeroProps) {
+ return (
+
+
+
+
+
+
+
+
+ {profile.displayName}
+
+ {isOwner && actor !== null ? (
+
+ {dict.profile.statusShort[actor.member.status]}
+
+ ) : null}
+
+
+
+ {profile.headline !== null || profile.affiliation !== null ? (
+
+
+ {profile.headline ?? profile.affiliation}
+
+ ) : null}
+
+ {profile.location !== null ? (
+
+
+ {profile.location}
+
+ ) : null}
+
+
+
+
+
+ {isOwner ? (
+
}
+ className="gap-2 cursor-pointer"
+ >
+
+
{dict.profile.title}
+
+ ) : null}
+
+
+
+
+ {copied ? (
+
+ Copied link!
+
+ ) : null}
+
+
+ }
+ className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90"
+ >
+
+
@{profile.githubUsername}
+
+
+
+
+ );
+}
diff --git a/apps/api/src/features/members/components/member-sidebar.tsx b/apps/api/src/features/members/components/member-sidebar.tsx
new file mode 100644
index 0000000..19f8fcc
--- /dev/null
+++ b/apps/api/src/features/members/components/member-sidebar.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { BuildingsIcon } from "@phosphor-icons/react";
+
+import type { MemberSidebarProps } from "../types/members.types";
+
+export function MemberSidebar({ skills, affiliation, links }: MemberSidebarProps) {
+ return (
+
+ {/* Skills Card */}
+ {skills.length > 0 ? (
+
+
Skills
+
+ {skills.map((skill) => (
+
+ {skill}
+
+ ))}
+
+
+ ) : null}
+
+ {/* Affiliation Card */}
+ {affiliation !== null ? (
+
+ ) : null}
+
+ {/* Links Card */}
+ {links.length > 0 ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/api/src/features/members/components/member-tabs.tsx b/apps/api/src/features/members/components/member-tabs.tsx
new file mode 100644
index 0000000..3bb9814
--- /dev/null
+++ b/apps/api/src/features/members/components/member-tabs.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import type { MemberTabsProps } from "../types/members.types";
+
+export function MemberTabs({ activeTab, onChangeTab }: MemberTabsProps) {
+ return (
+
+
+ onChangeTab("overview")}
+ className={`py-3.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
+ activeTab === "overview"
+ ? "border-primary text-primary font-semibold"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Overview
+
+ onChangeTab("contributions")}
+ className={`py-3.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
+ activeTab === "contributions"
+ ? "border-primary text-primary font-semibold"
+ : "border-transparent text-muted-foreground hover:text-foreground"
+ }`}
+ >
+ Contributions
+
+
+
+ );
+}
diff --git a/apps/api/src/features/members/constants.ts b/apps/api/src/features/members/constants.ts
new file mode 100644
index 0000000..e5756ac
--- /dev/null
+++ b/apps/api/src/features/members/constants.ts
@@ -0,0 +1,74 @@
+import type { MemberContribution } from "./types/members.types";
+
+export const SAMPLE_MEMBER_CONTRIBUTIONS: MemberContribution[] = [
+ {
+ id: "1",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "2",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "3",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "4",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "5",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "6",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "MERGED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "7",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "OPENED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "8",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "CLOSED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "9",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "CLOSED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+ {
+ id: "10",
+ author: "",
+ action: "improved the Nepali translation of the contribute page",
+ status: "CLOSED",
+ date: "14 Bhadra 2083 (30 Aug)",
+ },
+];
diff --git a/apps/api/src/features/members/hooks/use-member-directory.ts b/apps/api/src/features/members/hooks/use-member-directory.ts
new file mode 100644
index 0000000..7808721
--- /dev/null
+++ b/apps/api/src/features/members/hooks/use-member-directory.ts
@@ -0,0 +1,55 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { useMediaQuery, useMemberFilters, useMembers } from "@/hooks";
+import { MEMBER_SKILL_PREVIEW_COUNT } from "@/shared/constants";
+import type { SortMode, ViewMode } from "../types/members.types";
+
+/**
+ * Owns all stateful logic for the member directory page:
+ * filtering, sorting, view mode, and responsive compaction.
+ */
+export function useMemberDirectory() {
+ const { members, isLoading, error } = useMembers();
+ const { query, setQuery, skill, setSkill, filtered, clear } = useMemberFilters(members);
+
+ const [viewMode, setViewMode] = useState("grid");
+ // Table view requires ~860px to stay readable — phones always get the card grid.
+ const isCompact = useMediaQuery("(max-width: 639px)");
+ const effectiveView: ViewMode = isCompact ? "grid" : viewMode;
+
+ const [sortMode, setSortMode] = useState("featured");
+
+ const visible = useMemo(() => {
+ const rows = [...filtered];
+ if (sortMode === "skills") {
+ return rows.sort(
+ (a, b) => b.skills.length - a.skills.length || a.displayName.localeCompare(b.displayName),
+ );
+ }
+ return rows.sort((a, b) => a.displayName.localeCompare(b.displayName));
+ }, [filtered, sortMode]);
+
+ return {
+ members,
+ visible,
+ isLoading,
+ error,
+ // filter
+ query,
+ setQuery,
+ skill,
+ setSkill,
+ clear,
+ // view
+ viewMode,
+ setViewMode,
+ effectiveView,
+ // sort
+ sortMode,
+ setSortMode,
+ // derived
+ skillPreviewCount: MEMBER_SKILL_PREVIEW_COUNT,
+ };
+}
diff --git a/apps/api/src/features/members/hooks/use-member-profile.ts b/apps/api/src/features/members/hooks/use-member-profile.ts
new file mode 100644
index 0000000..f2a29fc
--- /dev/null
+++ b/apps/api/src/features/members/hooks/use-member-profile.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import type { PublicMemberDto } from "@gov-portal/shared";
+import { useState } from "react";
+import type { Actor } from "@/hooks";
+import { SHARE_TOAST_DURATION_MS } from "@/shared/constants";
+
+/**
+ * Owns share-button state + ownership/pending derivation for the member
+ * profile page. Keeps all stateful business logic out of the page file.
+ */
+export function useMemberProfile(profile: PublicMemberDto | undefined, actor: Actor | null) {
+ const [copied, setCopied] = useState(false);
+
+ const isOwner =
+ actor !== null && profile !== undefined && actor.member.githubId === profile.githubId;
+
+ const isPending = isOwner && actor !== null && actor.member.status !== "approved";
+
+ async function handleShare(): Promise {
+ const url = window.location.href;
+ if (navigator.share) {
+ try {
+ await navigator.share({ title: profile?.displayName ?? "Member Profile", url });
+ return;
+ } catch {
+ // fallback to clipboard
+ }
+ }
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), SHARE_TOAST_DURATION_MS);
+ } catch {
+ // silently ignore — clipboard may be unavailable
+ }
+ }
+
+ return { isOwner, isPending, handleShare, copied };
+}
diff --git a/apps/api/src/features/members/index.ts b/apps/api/src/features/members/index.ts
new file mode 100644
index 0000000..f31d5f6
--- /dev/null
+++ b/apps/api/src/features/members/index.ts
@@ -0,0 +1,9 @@
+export * from "./components/member-directory";
+export * from "./components/member-overview-tab";
+export * from "./components/member-profile-hero";
+export * from "./components/member-sidebar";
+export * from "./components/member-tabs";
+export * from "./constants";
+export * from "./hooks/use-member-directory";
+export * from "./hooks/use-member-profile";
+export * from "./types/members.types";
diff --git a/apps/api/src/features/members/types/members.types.ts b/apps/api/src/features/members/types/members.types.ts
new file mode 100644
index 0000000..621aa58
--- /dev/null
+++ b/apps/api/src/features/members/types/members.types.ts
@@ -0,0 +1,60 @@
+import type { PublicMemberDto } from "@gov-portal/shared";
+import type { Actor } from "@/hooks";
+import type { Dictionary, Locale } from "@/lib/i18n";
+
+export type SortMode = "featured" | "skills";
+export type ViewMode = "grid" | "table";
+export type MemberTab = "overview" | "contributions";
+
+export interface MemberContribution {
+ id: string;
+ author: string;
+ action: string;
+ status: "MERGED" | "OPENED" | "CLOSED";
+ date: string;
+}
+
+export interface MemberDirectoryProps {
+ members: PublicMemberDto[];
+ visible: PublicMemberDto[];
+ query: string;
+ setQuery: (q: string) => void;
+ skill: string;
+ setSkill: (s: string) => void;
+ clear: () => void;
+ viewMode: ViewMode;
+ setViewMode: (v: ViewMode) => void;
+ effectiveView: ViewMode;
+ sortMode: SortMode;
+ setSortMode: (s: SortMode) => void;
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface MemberProfileHeroProps {
+ profile: PublicMemberDto;
+ isOwner: boolean;
+ actor: Actor | null;
+ copied: boolean;
+ onShare: () => void;
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface MemberTabsProps {
+ activeTab: MemberTab;
+ onChangeTab: (tab: MemberTab) => void;
+}
+
+export interface MemberOverviewTabProps {
+ bio: string | null;
+ displayName: string;
+ contributions: MemberContribution[];
+}
+
+export interface MemberSidebarProps {
+ skills: string[];
+ affiliation: string | null;
+ location: string | null;
+ links: string[];
+}
diff --git a/apps/api/src/features/project/components/project-about-section.tsx b/apps/api/src/features/project/components/project-about-section.tsx
new file mode 100644
index 0000000..3759044
--- /dev/null
+++ b/apps/api/src/features/project/components/project-about-section.tsx
@@ -0,0 +1,45 @@
+import { Card } from "@/components/ui/card";
+import type { ProjectAboutSectionProps } from "../types/project.types";
+
+export function ProjectAboutSection({ project, dict }: ProjectAboutSectionProps) {
+ return (
+
+
+
+
+ {dict.project.aboutKicker}
+
+
+ {dict.project.aboutTitle}
+
+
+
+
+
+
+ {dict.project.contributeKicker}
+
+ {dict.project.contributeBody}
+
+
+
+ {dict.project.repository}
+
+
+
+ {project.fullName}
+
+
+
+
+
+ {dict.project.licence}
+
+
+ {project.license ?? dict.project.notPublished}
+
+
+
+
+ );
+}
diff --git a/apps/api/src/features/project/components/project-facts-sheet.tsx b/apps/api/src/features/project/components/project-facts-sheet.tsx
new file mode 100644
index 0000000..967f9c3
--- /dev/null
+++ b/apps/api/src/features/project/components/project-facts-sheet.tsx
@@ -0,0 +1,44 @@
+import type { ProjectFactsSheetProps } from "../types/project.types";
+
+export function ProjectFactsSheet({ project, synced, dict }: ProjectFactsSheetProps) {
+ return (
+
+
+
+ {dict.project.sheetTitle}
+
+ {project.license ?? dict.project.none}
+
+
+ {[
+ {
+ label: dict.project.repository,
+ value: (
+
+ {project.fullName}
+
+ ),
+ },
+ { label: dict.project.openIssues, value: project.openIssueCount },
+ { label: dict.project.members, value: project.memberCount },
+ ].map((item) => (
+
+
+ {item.label}
+
+ {item.value}
+
+ ))}
+
+
+ {dict.project.lastSync} · {synced}
+
+
+ );
+}
diff --git a/apps/api/src/features/project/components/project-hero-card.tsx b/apps/api/src/features/project/components/project-hero-card.tsx
new file mode 100644
index 0000000..eee5d29
--- /dev/null
+++ b/apps/api/src/features/project/components/project-hero-card.tsx
@@ -0,0 +1,63 @@
+import Link from "next/link";
+
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Chip } from "@/components/ui/chip";
+import { localePath } from "@/lib/i18n";
+import type { ProjectHeroCardProps } from "../types/project.types";
+
+export function ProjectHeroCard({ project, synced, locale, dict }: ProjectHeroCardProps) {
+ const [owner, name] = project.fullName.split("/");
+
+ return (
+
+
+
+
{owner} /
+
+ {name ?? project.fullName}
+
+
+ {project.description ?? dict.project.none}
+
+
+
+
+
+
{dict.project.openIssues}
+ {project.openIssueCount}
+
+
+
{dict.project.members}
+ {project.memberCount}
+
+
+
+
+
+ {dict.project.publicLabel}
+ {dict.project.openLabel}
+
+ {dict.project.lastSync} {" "}
+ {synced}
+
+
+
+
+
}>
+ {dict.project.chooseIssue}
+
+
}
+ >
+ {dict.project.viewOnGitHub}
+
+
{dict.project.githubSourceNote}
+
+
+ );
+}
diff --git a/apps/api/src/features/project/components/project-issues-section.tsx b/apps/api/src/features/project/components/project-issues-section.tsx
new file mode 100644
index 0000000..c8773a4
--- /dev/null
+++ b/apps/api/src/features/project/components/project-issues-section.tsx
@@ -0,0 +1,38 @@
+import { ArrowLink } from "@/components/modules/common";
+import { IssueRow } from "@/components/ui/issue-row";
+import { localePath } from "@/lib/i18n";
+import type { ProjectIssuesSectionProps } from "../types/project.types";
+
+export function ProjectIssuesSection({ recentIssues, locale, dict }: ProjectIssuesSectionProps) {
+ return (
+
+
+
+
+ {dict.project.issuesKicker}
+
+
+ {dict.project.issuesTitle}
+
+
+
{dict.project.allIssues} →
+
+ {recentIssues.length === 0 ? (
+
+
+ {dict.project.noIssues}
+
+
+ ) : (
+
+ {recentIssues.map((issue) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/api/src/features/project/hooks/use-project-page.ts b/apps/api/src/features/project/hooks/use-project-page.ts
new file mode 100644
index 0000000..8a2cbf5
--- /dev/null
+++ b/apps/api/src/features/project/hooks/use-project-page.ts
@@ -0,0 +1,21 @@
+"use client";
+
+import { useLocale, useProject, useProjectIssues } from "@/hooks";
+import { formatDateTime } from "@/lib/format";
+
+/**
+ * Aggregates all data fetching and derived values for the project page.
+ * Returns everything components need without exposing any render logic.
+ */
+export function useProjectPage() {
+ const { locale, dict } = useLocale();
+ const { project, isLoading, error } = useProject();
+ const { issues: recentIssues } = useProjectIssues({ page: 1, perPage: 8 }, project !== null);
+
+ const synced =
+ project !== null && project.lastSyncedAt !== null
+ ? formatDateTime(project.lastSyncedAt, locale)
+ : dict.project.never;
+
+ return { locale, dict, project, recentIssues, synced, isLoading, error };
+}
diff --git a/apps/api/src/features/project/index.ts b/apps/api/src/features/project/index.ts
new file mode 100644
index 0000000..f8ba879
--- /dev/null
+++ b/apps/api/src/features/project/index.ts
@@ -0,0 +1,6 @@
+export * from "./components/project-about-section";
+export * from "./components/project-facts-sheet";
+export * from "./components/project-hero-card";
+export * from "./components/project-issues-section";
+export * from "./hooks/use-project-page";
+export * from "./types/project.types";
diff --git a/apps/api/src/features/project/types/project.types.ts b/apps/api/src/features/project/types/project.types.ts
new file mode 100644
index 0000000..00b16ed
--- /dev/null
+++ b/apps/api/src/features/project/types/project.types.ts
@@ -0,0 +1,26 @@
+import type { Issue, Project } from "@gov-portal/api-client";
+import type { Dictionary, Locale } from "@/lib/i18n";
+
+export interface ProjectHeroCardProps {
+ project: Project;
+ synced: string;
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface ProjectAboutSectionProps {
+ project: Project;
+ dict: Dictionary;
+}
+
+export interface ProjectIssuesSectionProps {
+ recentIssues: Issue[];
+ locale: Locale;
+ dict: Dictionary;
+}
+
+export interface ProjectFactsSheetProps {
+ project: Project;
+ synced: string;
+ dict: Dictionary;
+}
diff --git a/apps/api/src/hooks/use-issues.ts b/apps/api/src/hooks/use-issues.ts
index 15a95d7..6c58539 100644
--- a/apps/api/src/hooks/use-issues.ts
+++ b/apps/api/src/hooks/use-issues.ts
@@ -15,11 +15,13 @@ function issuesKey(query: IssuesQuery): string {
page: String(query.page),
perPage: String(query.perPage),
});
- if (query.q !== undefined) {
- search.set("q", query.q);
+ const q = query.q?.trim();
+ const label = query.label?.trim();
+ if (q) {
+ search.set("q", q);
}
- if (query.label !== undefined) {
- search.set("label", query.label);
+ if (label) {
+ search.set("label", label);
}
return `/v1/project/issues?${search.toString()}`;
}
@@ -27,8 +29,17 @@ function issuesKey(query: IssuesQuery): string {
/** Paginated, filterable issue list for the given query. Pass `enabled: false`
* to skip fetching — e.g. while the parent project hasn't resolved yet. */
export function useProjectIssues(query: IssuesQuery, enabled = true) {
- const { data, error, isLoading } = useSWR(enabled ? issuesKey(query) : null, () =>
- apiClient.listIssues(query),
+ const q = query.q?.trim() || undefined;
+ const label = query.label?.trim() || undefined;
+ const cleanQuery: IssuesQuery = {
+ page: query.page,
+ perPage: query.perPage,
+ ...(q !== undefined ? { q } : {}),
+ ...(label !== undefined ? { label } : {}),
+ };
+
+ const { data, error, isLoading } = useSWR(enabled ? issuesKey(cleanQuery) : null, () =>
+ apiClient.listIssues(cleanQuery),
);
return {
issues: data?.issues ?? [],
diff --git a/apps/api/src/lib/i18n/dictionaries/en.ts b/apps/api/src/lib/i18n/dictionaries/en.ts
new file mode 100644
index 0000000..2f77dac
--- /dev/null
+++ b/apps/api/src/lib/i18n/dictionaries/en.ts
@@ -0,0 +1,346 @@
+export const en = {
+ brand: "Dev Nepal",
+ brandTagline: "Description",
+ govStrip: {
+ government: "नेपाल सरकार · Government of Nepal",
+ tagline: "Public collaboration portal",
+ emblemAlt: "Emblem of Nepal",
+ opensPortal: "(opens the national portal)",
+ language: "Language",
+ },
+ nav: {
+ projects: "Projects",
+ aboutPlatform: "About",
+ project: "The project",
+ issues: "Open issues",
+ members: "Members",
+ about: "How to contribute",
+ myProfile: "My profile",
+ admin: "Admin",
+ primary: "Primary",
+ menu: "Menu",
+ },
+ session: {
+ signIn: "Sign in with GitHub",
+ signOut: "Sign out",
+ greeting: "Signed in as",
+ },
+ welcome: {
+ kicker: "Onboarding",
+ pendingTitle: "You're signed in — pending review",
+ pendingBody:
+ "Your GitHub account is connected. The admin reviews new members before they appear in the public directory. Complete your profile now so approval is a formality.",
+ approvedTitle: "You're approved",
+ approvedBody: "Your profile is public. Start with the open issues.",
+ rejectedTitle: "Your profile was not approved",
+ rejectedBody:
+ "The admin did not approve this profile for the public directory. You can still keep your profile up to date; contact the maintainers if you think this was a mistake.",
+ hiddenTitle: "Your profile is hidden",
+ hiddenBody:
+ "This profile is currently hidden from the public directory. You can still edit it, and the admin can make it public again.",
+ completeProfile: "Complete your profile",
+ openProfile: "Open your profile",
+ browseIssues: "Browse open issues",
+ },
+ home: {
+ tag: "Open public work",
+ titleLine1: "Public technology,",
+ titleLine2: "built in public.",
+ lead: "One project publishes the technology work it needs help with, and anyone can contribute. The work happens in its public repository on GitHub, so no portal account is needed to start.",
+ browseIssues: "Browse open issues",
+ browseProject: "Open the project",
+ contributeKicker: "Who can contribute",
+ contributeTitle: "Nine ways to contribute",
+ contributeBody:
+ "Writing code is one of them. Design, testing, security, data, documentation, Nepali translation, research and community support all count.",
+ ways: [
+ "Engineering",
+ "UI/UX",
+ "QA",
+ "Security",
+ "Data",
+ "Documentation",
+ "Localization",
+ "Research",
+ "Community support",
+ ],
+ howToContribute: "How to contribute",
+ journeyKicker: "How it works",
+ journeyTitle: "From public project to GitHub contribution",
+ journey: {
+ step1Title: "The project is published",
+ step1Body: "A public repository and open issues that anyone can read.",
+ step2Title: "You pick an issue",
+ step2Body: "Open issues come straight from the project's public GitHub repository.",
+ step3Title: "You contribute on GitHub",
+ step3Body:
+ "Fork, branch and open a pull request. Discussion and review stay in the repository.",
+ step4Title: "Members can join",
+ step4Body: "Approved community profiles appear in the public member directory.",
+ },
+ openWorkKicker: "Open public work",
+ openWorkTitle: "The project",
+ openWorkBody: "One project, its repository, and the work waiting for a contribution.",
+ seeProject: "Open the project",
+ statsKicker: "Today on the portal",
+ openForContribTitle: "Featured project",
+ governmentOfNepal: "Government of Nepal",
+ programmeOwner: "Office of the Prime Minister and Council of Ministers",
+ stackLabel: "Stack",
+ membersTitle: "Community members",
+ openForContribDesc:
+ "The platform you are reading: the member directory, project listings and the contribution flow. Its own code is the first repository open for public contribution.",
+ openForContribTopics: ["TypeScript", "Next.js", "React", "Drizzle", "PostgreSQL"],
+ seeTheIssues: "See the Issues",
+ issuesPanelTitle: "Open Issues",
+ browseAllIssues: "Browse all issues",
+ githubActivityKicker: "GitHub activity",
+ githubActivityTitle: "Recently updated issues",
+ githubActivityBody:
+ "Live work from the public project repository, ordered by the latest GitHub update.",
+ viewAllMembers: "View all members",
+ howItWorksTitle: "How does it work ?",
+ howStep1Title: "Find work that fits",
+ howStep1Body:
+ "Browse open issues synchronised from the project's public GitHub repository and pick a task that fits your skills.",
+ howStep2Title: "Contribute on GitHub",
+ howStep2Body:
+ "No Dev Nepal account is needed to open a pull request. Discussion and review stay in the repository.",
+ howStep3Title: "Collect it in one place",
+ howStep3Body: "A profile is optional. Approved profiles appear in the public member directory.",
+ ctaTitle: "Need Support?",
+ ctaBody: "Reach out to us and we will get back to you.",
+ ctaAction: "Start contributing",
+ },
+ stats: {
+ openIssues: "Open issues",
+ members: "Approved members",
+ repository: "Repository",
+ lastSync: "Last GitHub sync",
+ never: "Not synced yet",
+ },
+ project: {
+ kicker: "Project",
+ publicLabel: "Public project",
+ openLabel: "Open",
+ chooseIssue: "Choose an issue",
+ viewOnGitHub: "View on GitHub",
+ aboutKicker: "About",
+ aboutTitle: "What this project is for",
+ aboutBody: "Where the work lives and who it serves.",
+ contributeKicker: "Contribute",
+ contributeTitle: "How to contribute",
+ contributeBody:
+ "Work happens in the repository: pick an open issue, discuss it there, and open a pull request. This portal tracks open issues and the approved member directory; it does not host code or handle review.",
+ issuesKicker: "Work waiting",
+ issuesTitle: "Open issues from GitHub",
+ allIssues: "See all open issues",
+ noIssues: "No open issues right now.",
+ sheetTitle: "Project sheet",
+ repository: "Repository",
+ licence: "Licence",
+ openIssues: "Open issues",
+ members: "Approved members",
+ lastSync: "Last GitHub sync",
+ never: "Not synced yet",
+ none: "—",
+ notPublished: "Not published",
+ startContributing: "Start contributing on GitHub",
+ githubSourceNote:
+ "GitHub is the source of truth for assignment, discussion, and pull requests.",
+ },
+ issues: {
+ kicker: "Open public work",
+ title: "Open issues",
+ lede: "Every open issue synchronised from the project's public repository. Pick one and work in the repository. No portal account is needed.",
+ openLabel: "open",
+ firstIssueLabel: "first issue",
+ filterBy: "Filter by",
+ allLabels: "All labels",
+ clear: "Clear",
+ searchLabel: "Search issues",
+ searchPlaceholder: "Search issues — नेपालीमा पनि खोज्नुहोस्",
+ search: "Search",
+ emptyTitle: "No open issues match this filter",
+ emptyBody:
+ "Issues appear here after the project repository is synchronised. Try clearing the filter.",
+ openedBy: "opened by",
+ comments: "comments",
+ updated: "updated",
+ readIssue: "Read issue",
+ github: "Open on GitHub",
+ goodFirst: "Good first issue",
+ breadcrumbProject: "The project",
+ issueLabel: "Issue",
+ },
+ issue: {
+ breadcrumbProjects: "The project",
+ stateOpen: "Open",
+ stateClosed: "Closed",
+ openedBy: "opened by",
+ comments: "comments",
+ startContributing: "Start contributing on GitHub",
+ sourceNote: "GitHub is the source of truth for assignment, discussion, and pull requests.",
+ noDescription: "No description was provided for this issue.",
+ },
+ members: {
+ kicker: "Public member directory",
+ title: "Members",
+ lede: "Members approved by the portal admin. Sign in with GitHub and complete your profile to be considered.",
+ count: "public members",
+ notice: "Profiles appear here only after the admin approves the member.",
+ searchLabel: "Search members",
+ searchPlaceholder: "Search names, skills, or interests",
+ skillLabel: "Skill",
+ allSkills: "All skills",
+ /** Labels for the fixed SKILLS taxonomy; keys must stay in sync with it. */
+ skillNames: {
+ engineering: "Engineering",
+ "ui-ux": "UI/UX",
+ qa: "Quality",
+ security: "Security",
+ data: "Data",
+ documentation: "Documentation",
+ localization: "Localization",
+ research: "Research",
+ community: "Community",
+ },
+ sortFeatured: "Name A-Z",
+ sortSkills: "Most skills",
+ sortLabel: "Sort",
+ search: "Search",
+ countMatches: "members match the current directory",
+ discoverable: "Approved member",
+ viewProfile: "View profile",
+ emptyTitle: "No members match these filters.",
+ emptyBody: "Try another name, headline, or skill.",
+ clear: "Clear",
+ },
+ member: {
+ kicker: "Member profile",
+ locationLabel: "Location",
+ affiliationLabel: "Affiliation",
+ skills: "Skills",
+ links: "Links",
+ viewOnGithub: "View profile on GitHub",
+ sharedNote:
+ "Profile details are member-provided. GitHub is the source of truth for repository activity.",
+ notFoundTitle: "Profile not available",
+ notFoundBody:
+ "This member either does not exist or has not been approved for the public directory.",
+ back: "Back to the directory",
+ ownerNote: "You are signed in as this member.",
+ },
+ profile: {
+ kicker: "Your profile",
+ title: "Edit your profile",
+ lede: "Your public profile and approval status.",
+ signInTitle: "Sign in to manage your profile",
+ signInBody: "Use your GitHub account. New accounts stay pending until the admin approves them.",
+ status: {
+ pending: "Your profile is awaiting review. It is not visible in the public directory yet.",
+ approved: "Your profile is approved and visible in the public directory.",
+ rejected: "Your profile was not approved for the public directory.",
+ hidden: "Your profile is currently hidden from the public directory.",
+ },
+ statusShort: {
+ pending: "Pending",
+ approved: "Approved",
+ rejected: "Rejected",
+ hidden: "Hidden",
+ },
+ fields: {
+ displayName: "Display name",
+ displayNameHelp: "Required, up to 80 characters, no URLs.",
+ headline: "Headline",
+ headlinePlaceholder: "e.g. Backend engineer",
+ affiliation: "Affiliation",
+ affiliationPlaceholder: "e.g. Tribhuvan University",
+ location: "Location",
+ locationPlaceholder: "e.g. Kathmandu",
+ bio: "Bio",
+ bioHelp: "Up to 400 characters of plain text.",
+ links: "Links",
+ linksHelp: "Up to 5 links, HTTPS only.",
+ addLink: "Add link",
+ remove: "Remove",
+ skills: "Skills",
+ skillsHelp: "Pick from the fixed taxonomy.",
+ },
+ save: "Save profile",
+ saving: "Saving…",
+ saved: "Profile saved.",
+ saveError: "Could not save your profile.",
+ asideTitle: "How approval works",
+ asideBody:
+ "Saving your profile is immediate. The admin approves, rejects, or hides profiles for the public directory.",
+ viewPublic: "View public profile",
+ },
+ admin: {
+ kicker: "Admin",
+ title: "Member moderation",
+ lede: "Approve, reject, hide, or prioritize members in the public directory.",
+ signInTitle: "Sign in to moderate members",
+ signInBody: "Only GitHub accounts listed in ADMIN_GITHUB_IDS can use this page.",
+ notAuthorizedTitle: "Not authorized",
+ notAuthorizedBody:
+ "This account is not an admin. Add the numeric GitHub ID to ADMIN_GITHUB_IDS to grant access.",
+ tabs: {
+ pending: "Pending",
+ approved: "Approved",
+ rejected: "Rejected",
+ hidden: "Hidden",
+ },
+ approve: "Approve",
+ reject: "Reject",
+ hide: "Hide",
+ priority: "Priority",
+ setPriority: "Set",
+ noMembers: "No members in this state.",
+ actionError: "Action failed.",
+ },
+ about: {
+ kicker: "About this portal",
+ title: "How to contribute",
+ lede: "A practical path from finding public-interest work to leaving a visible record of what changed.",
+ sections: {
+ findTitle: "Find open work",
+ findBody:
+ "Browse the project's open issues. Each one is a task maintained in the repository, with labels such as good first issue.",
+ workTitle: "Work on GitHub",
+ workBody:
+ "Comment on the issue to signal intent, fork the repository, and open a pull request. Discussion and review stay in the repository.",
+ recordTitle: "Leave a public record",
+ recordBody:
+ "Sign in with GitHub and keep your profile current. Approved members are listed in the public directory.",
+ limitsTitle: "What this portal is — and is not",
+ limitsBody:
+ "This portal indexes open issues and approved members. It is not a code host, not an employment offer, and not a guarantee that a contribution will be merged.",
+ },
+ actions: {
+ browseIssues: "Browse open issues",
+ openProject: "Open the project",
+ },
+ },
+ footer: {
+ note: "A Government of Nepal digital collaboration initiative for public technology.",
+ repository: "Source on GitHub",
+ platform: "Platform",
+ aboutPlatform: "About platform",
+ departments: "Project",
+ whosWho: "Members",
+ directorates: "Open issues",
+ legalCopyright:
+ "© 2026 Government of Nepal. Content available under the terms stated in the repository.",
+ },
+ common: {
+ loading: "Loading…",
+ skipToContent: "Skip to main content",
+ notFoundTitle: "Page not found",
+ notFoundBody: "That address does not exist on this portal.",
+ issue: "issue",
+ issues: "issues",
+ errorTitle: "Something went wrong",
+ retry: "Try again",
+ },
+};
diff --git a/apps/api/src/lib/i18n/dictionaries/index.ts b/apps/api/src/lib/i18n/dictionaries/index.ts
new file mode 100644
index 0000000..2b99bd0
--- /dev/null
+++ b/apps/api/src/lib/i18n/dictionaries/index.ts
@@ -0,0 +1,7 @@
+import type { Dictionary, Locale } from "../types";
+import { en } from "./en";
+import { ne } from "./ne";
+
+export const dictionaries: Record = { en, ne };
+
+export { en, ne };
diff --git a/apps/api/src/lib/i18n.ts b/apps/api/src/lib/i18n/dictionaries/ne.ts
similarity index 64%
rename from apps/api/src/lib/i18n.ts
rename to apps/api/src/lib/i18n/dictionaries/ne.ts
index c98dc4c..f053878 100644
--- a/apps/api/src/lib/i18n.ts
+++ b/apps/api/src/lib/i18n/dictionaries/ne.ts
@@ -1,358 +1,15 @@
-export const LOCALES = ["en", "ne"] as const;
+import type { Dictionary } from "../types";
-export type Locale = (typeof LOCALES)[number];
-
-export const DEFAULT_LOCALE: Locale = "en";
-
-export function isLocale(value: string): value is Locale {
- return (LOCALES as readonly string[]).includes(value);
-}
-
-const en = {
- brand: "Dev Nepal",
- brandTagline: "Description",
- nav: {
- projects: "Projects",
- aboutPlatform: "About",
- project: "The project",
- issues: "Open issues",
- members: "Members",
- about: "How to contribute",
- myProfile: "My profile",
- admin: "Admin",
- primary: "Primary",
- menu: "Menu",
- },
- session: {
- signIn: "Sign in with GitHub",
- signOut: "Sign out",
- greeting: "Signed in as",
- },
- welcome: {
- kicker: "Onboarding",
- pendingTitle: "You're signed in — pending review",
- pendingBody:
- "Your GitHub account is connected. The admin reviews new members before they appear in the public directory. Complete your profile now so approval is a formality.",
- approvedTitle: "You're approved",
- approvedBody: "Your profile is public. Start with the open issues.",
- rejectedTitle: "Your profile was not approved",
- rejectedBody:
- "The admin did not approve this profile for the public directory. You can still keep your profile up to date; contact the maintainers if you think this was a mistake.",
- hiddenTitle: "Your profile is hidden",
- hiddenBody:
- "This profile is currently hidden from the public directory. You can still edit it, and the admin can make it public again.",
- completeProfile: "Complete your profile",
- openProfile: "Open your profile",
- browseIssues: "Browse open issues",
- },
- home: {
- tag: "Open public work",
- titleLine1: "Public technology,",
- titleLine2: "built in public.",
- lead: "One project publishes the technology work it needs help with, and anyone can contribute. The work happens in its public repository on GitHub, so no portal account is needed to start.",
- browseIssues: "Browse open issues",
- browseProject: "Open the project",
- contributeKicker: "Who can contribute",
- contributeTitle: "Nine ways to contribute",
- contributeBody:
- "Writing code is one of them. Design, testing, security, data, documentation, Nepali translation, research and community support all count.",
- ways: [
- "Engineering",
- "UI/UX",
- "QA",
- "Security",
- "Data",
- "Documentation",
- "Localization",
- "Research",
- "Community support",
- ],
- howToContribute: "How to contribute",
- journeyKicker: "How it works",
- journeyTitle: "From public project to GitHub contribution",
- journey: {
- step1Title: "The project is published",
- step1Body: "A public repository and open issues that anyone can read.",
- step2Title: "You pick an issue",
- step2Body: "Open issues come straight from the project's public GitHub repository.",
- step3Title: "You contribute on GitHub",
- step3Body:
- "Fork, branch and open a pull request. Discussion and review stay in the repository.",
- step4Title: "Members can join",
- step4Body: "Approved community profiles appear in the public member directory.",
- },
- openWorkKicker: "Open public work",
- openWorkTitle: "The project",
- openWorkBody: "One project, its repository, and the work waiting for a contribution.",
- seeProject: "Open the project",
- statsKicker: "Today on the portal",
- openForContribTitle: "Featured project",
- governmentOfNepal: "Government of Nepal",
- programmeOwner: "Office of the Prime Minister and Council of Ministers",
- stackLabel: "Stack",
- membersTitle: "Community members",
- openForContribDesc:
- "The platform you are reading: the member directory, project listings and the contribution flow. Its own code is the first repository open for public contribution.",
- openForContribTopics: ["TypeScript", "Next.js", "React", "Drizzle", "PostgreSQL"],
- seeTheIssues: "See the Issues",
- issuesPanelTitle: "Open Issues",
- browseAllIssues: "Browse all issues",
- githubActivityKicker: "GitHub activity",
- githubActivityTitle: "Recently updated issues",
- githubActivityBody:
- "Live work from the public project repository, ordered by the latest GitHub update.",
- viewAllMembers: "View all members",
- howItWorksTitle: "How does it work ?",
- howStep1Title: "Find work that fits",
- howStep1Body:
- "Browse the open issues synced from the project's public GitHub repository and choose one that fits.",
- howStep2Title: "Contribute on GitHub",
- howStep2Body:
- "No Dev Nepal account is needed to open a pull request. Discussion and review stay on GitHub.",
- howStep3Title: "Collect it in one place",
- howStep3Body: "A profile is optional. Approved profiles appear in the public member directory.",
- ctaTitle: "Need Support?",
- ctaBody: "Reach out to us and we will get back to you.",
- ctaAction: "Start contributing",
- },
- stats: {
- openIssues: "Open issues",
- members: "Approved members",
- repository: "Repository",
- lastSync: "Last GitHub sync",
- never: "Not synced yet",
- },
- project: {
- kicker: "Project",
- publicLabel: "Public project",
- openLabel: "Open",
- chooseIssue: "Choose an issue",
- viewOnGitHub: "View on GitHub",
- aboutKicker: "About",
- aboutTitle: "What this project is for",
- aboutBody: "Where the work lives and who it serves.",
- contributeKicker: "Contribute",
- contributeTitle: "How to contribute",
- contributeBody:
- "Work happens in the repository: pick an open issue, discuss it there, and open a pull request. This portal tracks open issues and the approved member directory; it does not host code or handle review.",
- issuesKicker: "Work waiting",
- issuesTitle: "Open issues from GitHub",
- allIssues: "See all open issues",
- noIssues: "No open issues right now.",
- sheetTitle: "Project sheet",
- repository: "Repository",
- licence: "Licence",
- openIssues: "Open issues",
- members: "Approved members",
- lastSync: "Last GitHub sync",
- never: "Not synced yet",
- none: "—",
- notPublished: "Not published",
- startContributing: "Start contributing on GitHub",
- githubSourceNote:
- "GitHub is the source of truth for assignment, discussion, and pull requests.",
- },
- issues: {
- kicker: "Open public work",
- title: "Open issues",
- lede: "Every open issue synchronised from the project's public repository. Pick one and work in the repository. No portal account is needed.",
- openLabel: "open",
- firstIssueLabel: "first issue",
- filterBy: "Filter by",
- allLabels: "All labels",
- clear: "Clear",
- searchLabel: "Search issues",
- searchPlaceholder: "Search issues — नेपालीमा पनि खोज्नुहोस्",
- search: "Search",
- emptyTitle: "No open issues match this filter",
- emptyBody:
- "Issues appear here after the project repository is synchronised. Try clearing the filter.",
- openedBy: "opened by",
- comments: "comments",
- updated: "updated",
- readIssue: "Read issue",
- github: "Open on GitHub",
- goodFirst: "Good first issue",
- breadcrumbProject: "The project",
- issueLabel: "Issue",
- },
- issue: {
- breadcrumbProjects: "The project",
- stateOpen: "Open",
- stateClosed: "Closed",
- openedBy: "opened by",
- comments: "comments",
- startContributing: "Start contributing on GitHub",
- sourceNote: "GitHub is the source of truth for assignment, discussion, and pull requests.",
- noDescription: "No description was provided for this issue.",
- },
- members: {
- kicker: "Public member directory",
- title: "Members",
- lede: "Members approved by the portal admin. Sign in with GitHub and complete your profile to be considered.",
- count: "public members",
- notice: "Profiles appear here only after the admin approves the member.",
- searchLabel: "Search members",
- searchPlaceholder: "Search names, skills, or interests",
- skillLabel: "Skill",
- allSkills: "All skills",
- /** Labels for the fixed SKILLS taxonomy; keys must stay in sync with it. */
- skillNames: {
- engineering: "Engineering",
- "ui-ux": "UI/UX",
- qa: "Quality",
- security: "Security",
- data: "Data",
- documentation: "Documentation",
- localization: "Localization",
- research: "Research",
- community: "Community",
- },
- sortFeatured: "Name A-Z",
- sortSkills: "Most skills",
- sortLabel: "Sort",
- search: "Search",
- countMatches: "members match the current directory",
- discoverable: "Approved member",
- viewProfile: "View profile",
- emptyTitle: "No members match these filters.",
- emptyBody: "Try another name, headline, or skill.",
- clear: "Clear",
- },
- member: {
- kicker: "Member profile",
- locationLabel: "Location",
- affiliationLabel: "Affiliation",
- skills: "Skills",
- links: "Links",
- viewOnGithub: "View profile on GitHub",
- sharedNote:
- "Profile details are member-provided. GitHub is the source of truth for repository activity.",
- notFoundTitle: "Profile not available",
- notFoundBody:
- "This member either does not exist or has not been approved for the public directory.",
- back: "Back to the directory",
- ownerNote: "You are signed in as this member.",
- },
- profile: {
- kicker: "Your profile",
- title: "Edit your profile",
- lede: "Your public profile and approval status.",
- signInTitle: "Sign in to manage your profile",
- signInBody: "Use your GitHub account. New accounts stay pending until the admin approves them.",
- status: {
- pending: "Your profile is awaiting review. It is not visible in the public directory yet.",
- approved: "Your profile is approved and visible in the public directory.",
- rejected: "Your profile was not approved for the public directory.",
- hidden: "Your profile is currently hidden from the public directory.",
- },
- statusShort: {
- pending: "Pending",
- approved: "Approved",
- rejected: "Rejected",
- hidden: "Hidden",
- },
- fields: {
- displayName: "Display name",
- displayNameHelp: "Required, up to 80 characters, no URLs.",
- headline: "Headline",
- headlinePlaceholder: "e.g. Backend engineer",
- affiliation: "Affiliation",
- affiliationPlaceholder: "e.g. Tribhuvan University",
- location: "Location",
- locationPlaceholder: "e.g. Kathmandu",
- bio: "Bio",
- bioHelp: "Up to 400 characters of plain text.",
- links: "Links",
- linksHelp: "Up to 5 links, HTTPS only.",
- addLink: "Add link",
- remove: "Remove",
- skills: "Skills",
- skillsHelp: "Pick from the fixed taxonomy.",
- },
- save: "Save profile",
- saving: "Saving…",
- saved: "Profile saved.",
- saveError: "Could not save your profile.",
- asideTitle: "How approval works",
- asideBody:
- "Saving your profile is immediate. The admin approves, rejects, or hides profiles for the public directory.",
- viewPublic: "View public profile",
- },
- admin: {
- kicker: "Admin",
- title: "Member moderation",
- lede: "Approve, reject, hide, or prioritize members in the public directory.",
- signInTitle: "Sign in to moderate members",
- signInBody: "Only GitHub accounts listed in ADMIN_GITHUB_IDS can use this page.",
- notAuthorizedTitle: "Not authorized",
- notAuthorizedBody:
- "This account is not an admin. Add the numeric GitHub ID to ADMIN_GITHUB_IDS to grant access.",
- tabs: {
- pending: "Pending",
- approved: "Approved",
- rejected: "Rejected",
- hidden: "Hidden",
- },
- approve: "Approve",
- reject: "Reject",
- hide: "Hide",
- priority: "Priority",
- setPriority: "Set",
- noMembers: "No members in this state.",
- actionError: "Action failed.",
- },
- about: {
- kicker: "About this portal",
- title: "How to contribute",
- lede: "A practical path from finding public-interest work to leaving a visible record of what changed.",
- sections: {
- findTitle: "Find open work",
- findBody:
- "Browse the project's open issues. Each one is a task maintained in the repository, with labels such as good first issue.",
- workTitle: "Work on GitHub",
- workBody:
- "Comment on the issue to signal intent, fork the repository, and open a pull request. Discussion and review stay in the repository.",
- recordTitle: "Leave a public record",
- recordBody:
- "Sign in with GitHub and keep your profile current. Approved members are listed in the public directory.",
- limitsTitle: "What this portal is — and is not",
- limitsBody:
- "This portal indexes open issues and approved members. It is not a code host, not an employment offer, and not a guarantee that a contribution will be merged.",
- },
- actions: {
- browseIssues: "Browse open issues",
- openProject: "Open the project",
- },
- },
- footer: {
- note: "A Government of Nepal digital collaboration initiative for public technology.",
- repository: "Source on GitHub",
- platform: "Platform",
- aboutPlatform: "About platform",
- departments: "Project",
- whosWho: "Members",
- directorates: "Open issues",
- legalCopyright:
- "© 2026 Government of Nepal. Content available under the terms stated in the repository.",
- },
- common: {
- loading: "Loading…",
- skipToContent: "Skip to main content",
- notFoundTitle: "Page not found",
- notFoundBody: "That address does not exist on this portal.",
- issue: "issue",
- issues: "issues",
- errorTitle: "Something went wrong",
- retry: "Try again",
- },
-};
-
-export type Dictionary = typeof en;
-
-const ne: Dictionary = {
+export const ne: Dictionary = {
brand: "Dev Nepal",
brandTagline: "विवरण",
+ govStrip: {
+ government: "नेपाल सरकार · Government of Nepal",
+ tagline: "सार्वजनिक सहयोग मञ्च",
+ emblemAlt: "नेपालको राष्ट्रिय चिन्ह",
+ opensPortal: "(राष्ट्रिय पोर्टल खोल्छ)",
+ language: "भाषा",
+ },
nav: {
projects: "परियोजनाहरू",
aboutPlatform: "बारेमा",
@@ -681,14 +338,3 @@ const ne: Dictionary = {
retry: "फेरि प्रयास गर्नुहोस्",
},
};
-
-const dictionaries: Record = { en, ne };
-
-export function getDictionary(locale: Locale): Dictionary {
- return dictionaries[locale];
-}
-
-export function localePath(locale: Locale, path = ""): string {
- const normalized = path.startsWith("/") || path === "" ? path : `/${path}`;
- return `/${locale}${normalized}`;
-}
diff --git a/apps/api/src/lib/i18n/index.ts b/apps/api/src/lib/i18n/index.ts
new file mode 100644
index 0000000..8ade34a
--- /dev/null
+++ b/apps/api/src/lib/i18n/index.ts
@@ -0,0 +1,3 @@
+export * from "./dictionaries";
+export * from "./types";
+export * from "./utils";
diff --git a/apps/api/src/lib/i18n/types.ts b/apps/api/src/lib/i18n/types.ts
new file mode 100644
index 0000000..40d10d3
--- /dev/null
+++ b/apps/api/src/lib/i18n/types.ts
@@ -0,0 +1,13 @@
+import type { en } from "./dictionaries/en";
+
+export const LOCALES = ["en", "ne"] as const;
+
+export type Locale = (typeof LOCALES)[number];
+
+export const DEFAULT_LOCALE: Locale = "en";
+
+export function isLocale(value: string): value is Locale {
+ return (LOCALES as readonly string[]).includes(value);
+}
+
+export type Dictionary = typeof en;
diff --git a/apps/api/src/lib/i18n/utils.ts b/apps/api/src/lib/i18n/utils.ts
new file mode 100644
index 0000000..b288b29
--- /dev/null
+++ b/apps/api/src/lib/i18n/utils.ts
@@ -0,0 +1,11 @@
+import { dictionaries } from "./dictionaries";
+import type { Dictionary, Locale } from "./types";
+
+export function getDictionary(locale: Locale): Dictionary {
+ return dictionaries[locale];
+}
+
+export function localePath(locale: Locale, path = ""): string {
+ const normalized = path.startsWith("/") || path === "" ? path : `/${path}`;
+ return `/${locale}${normalized}`;
+}
diff --git a/apps/api/src/shared/constants.ts b/apps/api/src/shared/constants.ts
new file mode 100644
index 0000000..e0a203f
--- /dev/null
+++ b/apps/api/src/shared/constants.ts
@@ -0,0 +1,7 @@
+/** Pagination */
+export const ISSUES_PER_PAGE = 15;
+export const MEMBER_PREVIEW_COUNT = 8;
+export const MEMBER_SKILL_PREVIEW_COUNT = 2;
+
+/** Timing */
+export const SHARE_TOAST_DURATION_MS = 2000;
diff --git a/bun.lock b/bun.lock
index 8e9f7a2..fb78a72 100644
--- a/bun.lock
+++ b/bun.lock
@@ -22,6 +22,7 @@
"@gov-portal/api-client": "workspace:*",
"@gov-portal/shared": "workspace:*",
"@phosphor-icons/react": "^2.1.10",
+ "@t3-oss/env-nextjs": "^0.13.11",
"@tailwindcss/postcss": "^4.3.3",
"class-variance-authority": "^0.7.1",
"cn": "^0.3.0",
@@ -428,6 +429,10 @@
"@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="],
+ "@t3-oss/env-core": ["@t3-oss/env-core@0.13.11", "", { "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-sM7GYY+KL7H/Hl0BE0inWfk3nRHZOLhmVn7sHGxaZt9FAR6KqREXAE+6TqKfiavfXmpRxO/OZ2QgKRd+oiBYRQ=="],
+
+ "@t3-oss/env-nextjs": ["@t3-oss/env-nextjs@0.13.11", "", { "dependencies": { "@t3-oss/env-core": "0.13.11" }, "peerDependencies": { "arktype": "^2.1.0", "typescript": ">=5.0.0", "valibot": "^1.0.0-beta.7 || ^1.0.0", "zod": "^3.24.0 || ^4.0.0" }, "optionalPeers": ["arktype", "typescript", "valibot", "zod"] }, "sha512-NC+3j7YWgpzdFu1t5y/8wqibTK0lm5RS4bjXA1n8uwik3wIR4iZM4Fa+U2BaMa5k3Qk8RZiYhoAIX0WogmGkzg=="],
+
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
diff --git a/docs/frontend.md b/docs/frontend.md
index f67bac4..f788488 100644
--- a/docs/frontend.md
+++ b/docs/frontend.md
@@ -12,13 +12,22 @@ Auth.js internals, or environment configuration; the linter enforces this.
## Design
-The visual language is the DevNepal design system, ported from
-`voidash/DevNepal` (branch `demo/minimal-validated-flow`) and served from
-`apps/api/public/assets/devnepal/` — tokens, base, components, devnepal and
-public-discovery stylesheets, the vendored Primer base layer, Inter/Barlow
-fonts, and the emblem assets. See the README in that folder for provenance and
-licences. Use the existing `dn-*`, `btn`, `card`, `Label`, `tag` and
-`field` classes rather than inventing new ones.
+The visual language is a self-contained Tailwind v4 + shadcn token system
+defined in `apps/api/src/app/tailwind.css`. Semantic tokens (`--background`,
+`--card`, `--primary`, `--muted`, `--destructive`, `--chart-1…5`) each have a
+`.dark` counterpart, and theme switching runs through `next-themes` in
+`components/providers/theme-provider.tsx`.
+
+Use the semantic utilities (`bg-card`, `text-muted-foreground`,
+`border-border`) and the primitives in `components/ui/`. Do not reintroduce
+the legacy `dn-*`, `bg-paper`, `border-divider`, `accent-*` or `text-text`
+classes: the stylesheet that defined them is no longer loaded, so they
+resolve to nothing and render untinted.
+
+The national emblem (`public/official-website-bar/emblem-of-nepal.png`) marks
+government identity — the official website bar and the footer — and is also
+the favicon. The platform brand mark is `public/navbar/logo.svg`, used in the
+site header. Keep the two distinct.
## Environment