From 0ea46bf3f017e84202acd98e3e90a298b792957f Mon Sep 17 00:00:00 2001 From: rosan yonghang Date: Sun, 20 Sep 2026 17:59:57 +0545 Subject: [PATCH 1/2] fix(nav): fix logo for the emblem of dev nepal --- apps/api/src/app/layout.tsx | 4 ++-- .../components/modules/common/site-header.tsx | 7 +++++- docs/frontend.md | 23 +++++++++++++------ 3 files changed, 24 insertions(+), 10 deletions(-) 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/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 From 62a283b6cbec48b86c4c666e6bcb197093b41677 Mon Sep 17 00:00:00 2001 From: rosan yonghang Date: Sun, 20 Sep 2026 21:45:18 +0545 Subject: [PATCH 2/2] refactor: organize feature modules, fix issue filtering, split i18n and validate env --- apps/api/package.json | 1 + .../src/app/(site)/[locale]/issues/page.tsx | 225 ++-------- .../[locale]/members/[username]/page.tsx | 404 ++--------------- .../src/app/(site)/[locale]/members/page.tsx | 412 +----------------- .../src/app/(site)/[locale]/project/page.tsx | 193 +------- .../landing/project-spotlight-section.tsx | 2 +- apps/api/src/config.ts | 31 +- apps/api/src/env.ts | 30 ++ .../issues/components/issues-header.tsx | 25 ++ .../issues/components/issues-label-pills.tsx | 52 +++ .../issues/components/issues-list.tsx | 51 +++ .../issues/components/issues-pagination.tsx | 26 ++ .../issues/components/issues-search-form.tsx | 67 +++ .../features/issues/hooks/use-issues-page.ts | 72 +++ apps/api/src/features/issues/index.ts | 7 + .../src/features/issues/types/issues.types.ts | 46 ++ .../members/components/member-directory.tsx | 372 ++++++++++++++++ .../components/member-overview-tab.tsx | 58 +++ .../components/member-profile-hero.tsx | 115 +++++ .../members/components/member-sidebar.tsx | 80 ++++ .../members/components/member-tabs.tsx | 34 ++ apps/api/src/features/members/constants.ts | 74 ++++ .../members/hooks/use-member-directory.ts | 56 +++ .../members/hooks/use-member-profile.ts | 40 ++ apps/api/src/features/members/index.ts | 9 + .../features/members/types/members.types.ts | 60 +++ .../components/project-about-section.tsx | 45 ++ .../components/project-facts-sheet.tsx | 44 ++ .../project/components/project-hero-card.tsx | 63 +++ .../components/project-issues-section.tsx | 38 ++ .../project/hooks/use-project-page.ts | 21 + apps/api/src/features/project/index.ts | 6 + .../features/project/types/project.types.ts | 26 ++ apps/api/src/hooks/use-issues.ts | 23 +- apps/api/src/lib/i18n/dictionaries/en.ts | 357 +++++++++++++++ apps/api/src/lib/i18n/dictionaries/index.ts | 7 + .../lib/{i18n.ts => i18n/dictionaries/ne.ts} | 383 +--------------- apps/api/src/lib/i18n/index.ts | 3 + apps/api/src/lib/i18n/types.ts | 13 + apps/api/src/lib/i18n/utils.ts | 11 + apps/api/src/shared/constants.ts | 7 + bun.lock | 5 + 42 files changed, 2045 insertions(+), 1549 deletions(-) create mode 100644 apps/api/src/env.ts create mode 100644 apps/api/src/features/issues/components/issues-header.tsx create mode 100644 apps/api/src/features/issues/components/issues-label-pills.tsx create mode 100644 apps/api/src/features/issues/components/issues-list.tsx create mode 100644 apps/api/src/features/issues/components/issues-pagination.tsx create mode 100644 apps/api/src/features/issues/components/issues-search-form.tsx create mode 100644 apps/api/src/features/issues/hooks/use-issues-page.ts create mode 100644 apps/api/src/features/issues/index.ts create mode 100644 apps/api/src/features/issues/types/issues.types.ts create mode 100644 apps/api/src/features/members/components/member-directory.tsx create mode 100644 apps/api/src/features/members/components/member-overview-tab.tsx create mode 100644 apps/api/src/features/members/components/member-profile-hero.tsx create mode 100644 apps/api/src/features/members/components/member-sidebar.tsx create mode 100644 apps/api/src/features/members/components/member-tabs.tsx create mode 100644 apps/api/src/features/members/constants.ts create mode 100644 apps/api/src/features/members/hooks/use-member-directory.ts create mode 100644 apps/api/src/features/members/hooks/use-member-profile.ts create mode 100644 apps/api/src/features/members/index.ts create mode 100644 apps/api/src/features/members/types/members.types.ts create mode 100644 apps/api/src/features/project/components/project-about-section.tsx create mode 100644 apps/api/src/features/project/components/project-facts-sheet.tsx create mode 100644 apps/api/src/features/project/components/project-hero-card.tsx create mode 100644 apps/api/src/features/project/components/project-issues-section.tsx create mode 100644 apps/api/src/features/project/hooks/use-project-page.ts create mode 100644 apps/api/src/features/project/index.ts create mode 100644 apps/api/src/features/project/types/project.types.ts create mode 100644 apps/api/src/lib/i18n/dictionaries/en.ts create mode 100644 apps/api/src/lib/i18n/dictionaries/index.ts rename apps/api/src/lib/{i18n.ts => i18n/dictionaries/ne.ts} (63%) create mode 100644 apps/api/src/lib/i18n/index.ts create mode 100644 apps/api/src/lib/i18n/types.ts create mode 100644 apps/api/src/lib/i18n/utils.ts create mode 100644 apps/api/src/shared/constants.ts diff --git a/apps/api/package.json b/apps/api/package.json index 0286d47..de6f947 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" - /> + - -
{ - event.preventDefault(); - const form = new FormData(event.currentTarget); - navigate({ q: String(form.get("q") ?? ""), page: 1 }); - }} - > - - {dict.issues.searchLabel} - - - - {dict.issues.filterBy} - {/* Native select: the platform control's keyboard and mobile - behaviour suits a filter better than a custom listbox. */} - - - -
-
+ -
- - {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}

- -
- ) : ( -
- {issues.map((issue) => ( - - ))} -
- )} + - {totalPages > 1 ? ( - - ) : 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 6eb41ec..d1157b2 100644 --- a/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx +++ b/apps/api/src/app/(site)/[locale]/members/[username]/page.tsx @@ -1,23 +1,19 @@ "use client"; -import { - BriefcaseIcon, - BuildingsIcon, - CaretRightIcon, - 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 type { MemberTab } from "@/features/members"; +import { + MemberOverviewTab, + MemberProfileHero, + MemberSidebar, + MemberTabs, + SAMPLE_MEMBER_CONTRIBUTIONS, + useMemberProfile, +} from "@/features/members"; import { useActor, useLocale, useMember } from "@/hooks"; import { ApiError } from "@/lib/api-error"; import { localePath } from "@/lib/i18n"; @@ -27,31 +23,8 @@ export default function MemberDetailPage() { const { username } = useParams<{ username: string }>(); const { member: profile, isLoading, error } = useMember(username); const { actor } = useActor(); - const [activeTab, setActiveTab] = useState<"overview" | "contributions">("overview"); - 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 { - // Fallback to clipboard - } - } - - try { - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - // Fallback - } - }; + const [activeTab, setActiveTab] = useState("overview"); + const { isOwner, isPending, handleShare, copied } = useMemberProfile(profile, actor); if (error instanceof ApiError && error.status === 404) { notFound(); @@ -72,83 +45,6 @@ export default function MemberDetailPage() { ); } - const isOwner = actor !== null && actor.member.githubId === profile.githubId; - const isPending = actor !== null && isOwner && actor.member.status !== "approved"; - - // Sample/mock contributions matching the visual spec if none available - const sampleContributions = [ - { - id: "1", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "2", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "3", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "4", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "5", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "6", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "MERGED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "7", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "OPENED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "8", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "CLOSED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "9", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "CLOSED", - date: "14 Bhadra 2083 (30 Aug)", - }, - { - id: "10", - author: profile.displayName, - action: "improved the Nepali translation of the contribute page", - status: "CLOSED", - date: "14 Bhadra 2083 (30 Aug)", - }, - ]; - return (
{/* Breadcrumb Bar */} @@ -171,270 +67,38 @@ 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} - - ) : ( - - - Kathmandu Nepal - - )} -
-
-
+ -
- {isOwner ? ( - - ) : null} -
- - {copied ? ( - - Copied link! - - ) : null} -
- -
-
-
+ - {/* Tabs Navigation Strip */} -
-
- - -
-
- - {/* Main Content Area */}
- {isPending ? ( + {isPending && actor !== null ? ( {dict.profile.status[actor.member.status]} ) : null}
- {/* Left Main Column */} -
- {/* Bio Prose */} -
-

- {profile.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 -

- -
- {sampleContributions.map((item) => ( -
-
- {item.author} - {item.action} -
- -
- {item.status === "MERGED" ? ( - - {item.status} - - ) : item.status === "OPENED" ? ( - - {item.status} - - ) : ( - - {item.status} - - )} - - {item.date} - - -
-
- ))} -
-
-
- - {/* Right Sidebar Column */} -
- {/* Skills Card */} -
-

Skills

-
- {profile.skills.length > 0 - ? profile.skills.map((skill) => ( - - {skill} - - )) - : [ - "Engineering", - "UI/UX", - "Data", - "Security", - "Documentation", - "Localization", - "Research", - ].map((skill) => ( - - {skill} - - ))} -
-
- - {/* Affiliation Card */} -
-

Affiliation

-
-
- - {profile.affiliation ?? "Niural AI"} -
-
- - MIT -
-
-
- - {/* Links Card */} -
-

Links

-
- {profile.links.length > 0 - ? profile.links.map((link) => ( - - {link.replace(/^https?:\/\//, "")} - - )) - : ["github.com", "linkedin.com", "dev.to", "twitter.com", "personal.site"].map( - (link) => ( - - {link} - - ), - )} -
-
-
+ + +
diff --git a/apps/api/src/app/(site)/[locale]/members/page.tsx b/apps/api/src/app/(site)/[locale]/members/page.tsx index fc9f8ed..5d32115 100644 --- a/apps/api/src/app/(site)/[locale]/members/page.tsx +++ b/apps/api/src/app/(site)/[locale]/members/page.tsx @@ -1,63 +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 = "featured" | "recent" | "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("featured"); - - // 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), - ); - } - if (sortMode === "recent") { - return rows.reverse(); - } - 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()} @@ -66,347 +24,21 @@ export default function MembersPage() { } return ( -
- {/* Deep Brand Blue Hero Header */} -
-
-

- {dict.members.title} -

-

- People who chose to contribute. -

-
-
- - {/* Category Filter Tabs */} -
-
- - {categories.map((entry) => ( - - ))} -
-
- - {/* Search, Sort, and View Controls */} -
-
- {/* Search Box */} -
e.preventDefault()} - className="flex w-full items-center gap-2 sm:max-w-md" - > - setQuery(e.target.value)} - className="h-10 bg-card" - /> - -
- - {/* Right Controls: Sort & Grid/Table Toggle */} -
- {/* Sort Dropdown */} - - - {/* View Mode Switcher — table view is unavailable on phones. */} -
- - -
-
-
- - {/* Directory Content */} -
- {visible.length === 0 ? ( -
- - {dict.members.emptyTitle} - -

{dict.members.emptyBody}

- -
- ) : 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 ?? "Niural AI"} - - | - - - {member.location ?? "Kathmandu Nepal"} - -
- - {/* Skills Chips */} - {(() => { - const allSkills = - member.skills.length > 0 - ? member.skills - : ["engineering", "security", "data"]; - const shown = allSkills.slice(0, 2); - const hidden = allSkills.slice(2); - return ( -
- {shown.map((skill) => ( - - {skill} - - ))} - {hidden.length > 0 ? ( -
- - +{hidden.length} more - -
-
- {hidden.map((skill) => ( - - {skill} - - ))} -
-
-
- ) : null} -
- ); - })()} -
- - {/* View Profile Action */} -
- -
-
- ))} -
- ) : ( - /* Table View */ -
-
- - - - - - - - - - - - - - {visible.map((member) => ( - - {/* Member */} - - - {/* Role */} - - - {/* Organisation */} - - - {/* City */} - - - {/* Field */} - - - {/* Skills */} - - - {/* Contributions */} - - - ))} - -
MEMBERROLEORGANISATIONCITYFIELDSKILLSCONTRIBUTIONS
-
- - - - {member.displayName.slice(0, 2).toUpperCase()} - - -
- - {member.displayName} - - - @{member.githubUsername} - -
-
-
- {member.headline ?? "UX researcher"} - - {member.affiliation ?? "Niural AI"} - - {member.location ?? "Patan"} - Design -
- {(member.skills.length > 0 - ? member.skills - : ["Design", "Writing", "UX Research"] - ).map((skill) => ( - - {skill} - - ))} -
-
-
- 50 - - ⊕ - -
-
-
-
- )} -
-
-
+ ); } 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.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/components/modules/landing/project-spotlight-section.tsx b/apps/api/src/components/modules/landing/project-spotlight-section.tsx index 8321482..85a3b9c 100644 --- a/apps/api/src/components/modules/landing/project-spotlight-section.tsx +++ b/apps/api/src/components/modules/landing/project-spotlight-section.tsx @@ -109,7 +109,7 @@ export function ProjectSpotlightSection({
- MIT Licence + {project.license ?? "MIT Licence"} 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}

+ +
+ ); + } + + 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 ( + + ); +} 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 ( + +
{ + event.preventDefault(); + const form = new FormData(event.currentTarget); + const nextQ = String(form.get("q") ?? "").trim(); + const nextLabel = String(form.get("label") ?? "").trim(); + onNavigate({ + q: nextQ || undefined, + label: nextLabel || undefined, + page: 1, + }); + }} + > + + {dict.issues.searchLabel} + + + + {dict.issues.filterBy} + + + +
+
+ ); +} 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..7977dd0 --- /dev/null +++ b/apps/api/src/features/members/components/member-directory.tsx @@ -0,0 +1,372 @@ +"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 */} +
+
+ + {categories.map((entry) => ( + + ))} +
+
+ + {/* Controls */} +
+
+
e.preventDefault()} + className="flex w-full items-center gap-2 sm:max-w-md" + > + setQuery(e.target.value)} + className="h-10 bg-card" + /> + +
+ +
+ + +
+ + +
+
+
+ + {/* Results */} +
+ {visible.length === 0 ? ( +
+ + {dict.members.emptyTitle} + +

{dict.members.emptyBody}

+ +
+ ) : 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 ?? "Niural AI"} + + | + + + {member.location ?? "Kathmandu Nepal"} + +
+ + +
+ +
+ +
+
+ ))} +
+ ); +} + +/* ─── Table View ─────────────────────────────────────────────────────────── */ + +function MemberTableView({ + members, + locale, + dict, +}: { + members: PublicMemberDto[]; + locale: Locale; + dict: Dictionary; +}) { + return ( +
+
+ + + + + + + + + + + + + {members.map((member) => ( + + + + + + + + + ))} + +
MEMBERROLEORGANISATIONCITYSKILLSCONTRIBUTIONS
+
+ + + + {member.displayName.slice(0, 2).toUpperCase()} + + +
+ + {member.displayName} + + + @{member.githubUsername} + +
+
+
+ {member.headline ?? "Contributor"} + + {member.affiliation ?? "—"} + {member.location ?? "—"} +
+ {(member.skills as string[]).slice(0, 3).map((s) => ( + + {s} + + ))} +
+
+ + ⊕ + +
+
+
+ ); +} + +/* ─── Skill chips (shared sub-component) ────────────────────────────────── */ + +function MemberSkillChips({ skills }: { skills: string[] }) { + const all = skills.length > 0 ? skills : ["engineering", "security", "data"]; + const shown = all.slice(0, 2); + const hidden = all.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..385b765 --- /dev/null +++ b/apps/api/src/features/members/components/member-profile-hero.tsx @@ -0,0 +1,115 @@ +"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 ?? "Kathmandu Nepal"} + +
+
+
+ +
+ {isOwner ? ( + + ) : null} +
+ + {copied ? ( + + Copied link! + + ) : null} +
+ +
+
+
+ ); +} 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..0b2a645 --- /dev/null +++ b/apps/api/src/features/members/components/member-sidebar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { BuildingsIcon } from "@phosphor-icons/react"; + +import type { MemberSidebarProps } from "../types/members.types"; + +export function MemberSidebar({ skills, affiliation, links }: MemberSidebarProps) { + const fallbackSkills = [ + "Engineering", + "UI/UX", + "Data", + "Security", + "Documentation", + "Localization", + "Research", + ]; + + const fallbackLinks = ["github.com", "linkedin.com", "dev.to", "twitter.com", "personal.site"]; + + return ( +
+ {/* Skills Card */} +
+

Skills

+
+ {(skills.length > 0 ? skills : fallbackSkills).map((skill) => ( + + {skill} + + ))} +
+
+ + {/* Affiliation Card */} +
+

Affiliation

+
+
+ + {affiliation ?? "Niural AI"} +
+
+ + MIT +
+
+
+ + {/* Links Card */} +
+

Links

+
+ {links.length > 0 + ? links.map((link) => ( + + {link.replace(/^https?:\/\//, "")} + + )) + : fallbackLinks.map((link) => ( + + {link} + + ))} +
+
+
+ ); +} 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 ( +
+
+ + +
+
+ ); +} 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..064c8cc --- /dev/null +++ b/apps/api/src/features/members/hooks/use-member-directory.ts @@ -0,0 +1,56 @@ +"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), + ); + } + if (sortMode === "recent") return rows.reverse(); + 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..bc02e44 --- /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" | "recent" | "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.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..98be920 --- /dev/null +++ b/apps/api/src/lib/i18n/dictionaries/en.ts @@ -0,0 +1,357 @@ +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, a licence, 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: "The work is recorded", + step4Body: "Approved contributors 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", + stackLabel: "Stack", + contributorsTitle: "Contributors", + viewAllContributors: "View all contributors", + openForContribOwner: "Office of the Prime Minister and Council of Ministers", + 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", + viewAllMembers: "View all members", + howItWorksTitle: "How does it work ?", + howStep1Title: "Find work that fits", + howStep1Body: + "Every issue states its context, what is in scope, what is explicitly out of scope, and roughly how long it should take.", + howStep2Title: "Contribute on GitHub", + howStep2Body: + "No DevNepal account is needed to open a pull request. The mentor named on the issue answers questions in the open.", + howStep3Title: "Collect it in one place", + howStep3Body: + "A profile is optional. If you make one, your accepted contributions gather on a public page you control.", + 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", + 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", + sortRecent: "Recently approved", + 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: + "This member completed this profile after admin approval. GitHub remains the source of their contribution record.", + 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 Digital Collaboration Initiative of the Office of the Prime Minister and Council of Ministers, Government of Nepal. The platform's own code is open source.", + repository: "Source on GitHub", + platform: "Platform", + aboutPlatform: "About platform", + departments: "Departments", + whosWho: "Who's who", + directorates: "Directorates/Commissionerates", + governance: "Governance", + codeOfConduct: "Code of conduct", + security: "Security", + privacyNotice: "Privacy notice", + licence: "Licence", + contact: "Contact", + emailSecurity: "security@devnepal.com", + emailConduct: "conduct@devnepal.com", + ministrySignIn: "Ministry sign in", + scheme4: "Scheme 4", + account: "Account", + legalCopyright: + "© 2026 Government of Nepal. Content available under the terms stated in the repository.", + termsConditions: "Terms & Conditions", + privacyPolicy: "Privacy policy", + contactUs: "Contact Us", + }, + 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 63% rename from apps/api/src/lib/i18n.ts rename to apps/api/src/lib/i18n/dictionaries/ne.ts index 46ad6fe..aa5afc7 100644 --- a/apps/api/src/lib/i18n.ts +++ b/apps/api/src/lib/i18n/dictionaries/ne.ts @@ -1,374 +1,6 @@ -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", - 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, a licence, 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: "The work is recorded", - step4Body: "Approved contributors 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", - stackLabel: "Stack", - contributorsTitle: "Contributors", - viewAllContributors: "View all contributors", - openForContribOwner: "Office of the Prime Minister and Council of Ministers", - 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", - viewAllMembers: "View all members", - howItWorksTitle: "How does it work ?", - howStep1Title: "Find work that fits", - howStep1Body: - "Every issue states its context, what is in scope, what is explicitly out of scope, and roughly how long it should take.", - howStep2Title: "Contribute on GitHub", - howStep2Body: - "No DevNepal account is needed to open a pull request. The mentor named on the issue answers questions in the open.", - howStep3Title: "Collect it in one place", - howStep3Body: - "A profile is optional. If you make one, your accepted contributions gather on a public page you control.", - 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", - 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", - sortRecent: "Recently approved", - 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: - "This member completed this profile after admin approval. GitHub remains the source of their contribution record.", - 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 Digital Collaboration Initiative of the Office of the Prime Minister and Council of Ministers, Government of Nepal. The platform's own code is open source.", - repository: "Source on GitHub", - platform: "Platform", - aboutPlatform: "About platform", - departments: "Departments", - whosWho: "Who's who", - directorates: "Directorates/Commissionerates", - governance: "Governance", - codeOfConduct: "Code of conduct", - security: "Security", - privacyNotice: "Privacy notice", - licence: "Licence", - contact: "Contact", - emailSecurity: "security@devnepal.com", - emailConduct: "conduct@devnepal.com", - ministrySignIn: "Ministry sign in", - scheme4: "Scheme 4", - account: "Account", - legalCopyright: - "© 2026 Government of Nepal. Content available under the terms stated in the repository.", - termsConditions: "Terms & Conditions", - privacyPolicy: "Privacy policy", - contactUs: "Contact Us", - }, - 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: { @@ -718,14 +350,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=="],