svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+export {
+ Avatar,
+ AvatarImage,
+ AvatarFallback,
+ AvatarGroup,
+ AvatarGroupCount,
+ AvatarBadge,
+}
diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx
new file mode 100644
index 00000000..bdb5bdeb
--- /dev/null
+++ b/src/components/ui/card.tsx
@@ -0,0 +1,103 @@
+import * as React from "react"
+
+import { cn } from "@/lib/utils"
+
+function Card({
+ className,
+ size = "default",
+ ...props
+}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
+ return (
+
img:first-child]:pt-0 data-[size=sm]:gap-4 data-[size=sm]:py-4 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 00000000..a66452e8
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,155 @@
+import * as React from "react"
+import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
+
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import { XIcon } from "lucide-react"
+
+function Dialog({ ...props }: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
+ return
+}
+
+function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
+ return
+}
+
+function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogOverlay({
+ className,
+ ...props
+}: DialogPrimitive.Backdrop.Props) {
+ return (
+
+ )
+}
+
+function DialogContent({
+ className,
+ children,
+ showCloseButton = true,
+ ...props
+}: DialogPrimitive.Popup.Props & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+
+
+ {children}
+ {showCloseButton && (
+
+ }
+ >
+
+ Close
+
+ )}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function DialogFooter({
+ className,
+ showCloseButton = false,
+ children,
+ ...props
+}: React.ComponentProps<"div"> & {
+ showCloseButton?: boolean
+}) {
+ return (
+
+ {children}
+ {showCloseButton && (
+ }>
+ Close
+
+ )}
+
+ )
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return (
+
+ )
+}
+
+function DialogDescription({
+ className,
+ ...props
+}: DialogPrimitive.Description.Props) {
+ return (
+
+ )
+}
+
+export {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogOverlay,
+ DialogPortal,
+ DialogTitle,
+ DialogTrigger,
+}
diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx
new file mode 100644
index 00000000..82ccce4c
--- /dev/null
+++ b/src/components/ui/input.tsx
@@ -0,0 +1,20 @@
+import * as React from "react"
+import { Input as InputPrimitive } from "@base-ui/react/input"
+
+import { cn } from "@/lib/utils"
+
+function Input({ className, type, ...props }: React.ComponentProps<"input">) {
+ return (
+
+ )
+}
+
+export { Input }
diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx
new file mode 100644
index 00000000..4ed056a3
--- /dev/null
+++ b/src/components/ui/select.tsx
@@ -0,0 +1,199 @@
+import * as React from "react"
+import { Select as SelectPrimitive } from "@base-ui/react/select"
+
+import { cn } from "@/lib/utils"
+import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
+
+const Select = SelectPrimitive.Root
+
+function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
+ return (
+
+ )
+}
+
+function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
+ return (
+
+ )
+}
+
+function SelectTrigger({
+ className,
+ size = "default",
+ children,
+ ...props
+}: SelectPrimitive.Trigger.Props & {
+ size?: "sm" | "default"
+}) {
+ return (
+
+ {children}
+
+ }
+ />
+
+ )
+}
+
+function SelectContent({
+ className,
+ children,
+ side = "bottom",
+ sideOffset = 4,
+ align = "center",
+ alignOffset = 0,
+ alignItemWithTrigger = true,
+ ...props
+}: SelectPrimitive.Popup.Props &
+ Pick<
+ SelectPrimitive.Positioner.Props,
+ "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
+ >) {
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ )
+}
+
+function SelectLabel({
+ className,
+ ...props
+}: SelectPrimitive.GroupLabel.Props) {
+ return (
+
+ )
+}
+
+function SelectItem({
+ className,
+ children,
+ ...props
+}: SelectPrimitive.Item.Props) {
+ return (
+
+
+ {children}
+
+
+ }
+ >
+
+
+
+ )
+}
+
+function SelectSeparator({
+ className,
+ ...props
+}: SelectPrimitive.Separator.Props) {
+ return (
+
+ )
+}
+
+function SelectScrollUpButton({
+ className,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+
+
+ )
+}
+
+function SelectScrollDownButton({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+
+ )
+}
+
+export {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+ SelectLabel,
+ SelectScrollDownButton,
+ SelectScrollUpButton,
+ SelectSeparator,
+ SelectTrigger,
+ SelectValue,
+}
diff --git a/src/components/ui/skeleton.tsx b/src/components/ui/skeleton.tsx
new file mode 100644
index 00000000..0118624f
--- /dev/null
+++ b/src/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "@/lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts
index 60a4eafd..4ec2cea2 100644
--- a/src/routeTree.gen.ts
+++ b/src/routeTree.gen.ts
@@ -10,14 +10,21 @@
import { Route as rootRouteImport } from './routes/__root'
import { Route as SigsRouteImport } from './routes/sigs'
+import { Route as ProjectsRouteImport } from './routes/projects'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as ProjectProjectIdRouteImport } from './routes/project/$projectId'
const SigsRoute = SigsRouteImport.update({
id: '/sigs',
path: '/sigs',
getParentRoute: () => rootRouteImport,
} as any)
+const ProjectsRoute = ProjectsRouteImport.update({
+ id: '/projects',
+ path: '/projects',
+ getParentRoute: () => rootRouteImport,
+} as any)
const AboutRoute = AboutRouteImport.update({
id: '/about',
path: '/about',
@@ -28,35 +35,54 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const ProjectProjectIdRoute = ProjectProjectIdRouteImport.update({
+ id: '/project/$projectId',
+ path: '/project/$projectId',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
+ '/project/$projectId': typeof ProjectProjectIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
+ '/project/$projectId': typeof ProjectProjectIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
+ '/projects': typeof ProjectsRoute
'/sigs': typeof SigsRoute
+ '/project/$projectId': typeof ProjectProjectIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/about' | '/sigs'
+ fullPaths: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/about' | '/sigs'
- id: '__root__' | '/' | '/about' | '/sigs'
+ to: '/' | '/about' | '/projects' | '/sigs' | '/project/$projectId'
+ id:
+ | '__root__'
+ | '/'
+ | '/about'
+ | '/projects'
+ | '/sigs'
+ | '/project/$projectId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
+ ProjectsRoute: typeof ProjectsRoute
SigsRoute: typeof SigsRoute
+ ProjectProjectIdRoute: typeof ProjectProjectIdRoute
}
declare module '@tanstack/react-router' {
@@ -68,6 +94,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof SigsRouteImport
parentRoute: typeof rootRouteImport
}
+ '/projects': {
+ id: '/projects'
+ path: '/projects'
+ fullPath: '/projects'
+ preLoaderRoute: typeof ProjectsRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/about': {
id: '/about'
path: '/about'
@@ -82,13 +115,22 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/project/$projectId': {
+ id: '/project/$projectId'
+ path: '/project/$projectId'
+ fullPath: '/project/$projectId'
+ preLoaderRoute: typeof ProjectProjectIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
+ ProjectsRoute: ProjectsRoute,
SigsRoute: SigsRoute,
+ ProjectProjectIdRoute: ProjectProjectIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/src/routes/project/$projectId.tsx b/src/routes/project/$projectId.tsx
new file mode 100644
index 00000000..686d9d7e
--- /dev/null
+++ b/src/routes/project/$projectId.tsx
@@ -0,0 +1,453 @@
+import { SiGithub } from "@icons-pack/react-simple-icons";
+import { queryOptions, useQuery } from "@tanstack/react-query";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import axios from "axios";
+import { ChevronLeft, Maximize2, Play } from "lucide-react";
+import { useCallback, useMemo, useState } from "react";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import {
+ Carousel,
+ CarouselContent,
+ CarouselItem,
+ CarouselNext,
+ CarouselPrevious,
+} from "@/components/ui/carousel";
+import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+export const Route = createFileRoute("/project/$projectId")({
+ component: Project,
+ loader: async ({ context: { queryClient }, params: { projectId } }) => {
+ await queryClient.ensureQueryData(projectDetailQueryOptions(projectId));
+ await queryClient.ensureQueryData(projectMediaQueryOptions(projectId));
+ },
+});
+
+/// Types and Interfaces
+
+type ProjectType =
+ | "independent"
+ | "sig_ai"
+ | "sig_swe"
+ | "sig_cyber"
+ | "sig_data"
+ | "sig_arch"
+ | "sig_graph";
+
+interface ProjectMember {
+ id: string;
+ name: string;
+}
+
+interface ProjectThumbnail {
+ hash: string;
+ url: string;
+}
+
+interface FullProject {
+ id: string;
+ name: string;
+ description: string;
+ link: string;
+ thumbnail?: ProjectThumbnail;
+ members: ProjectMember[];
+ type: ProjectType;
+ tags?: string[];
+ active: boolean;
+ founded_at: string;
+}
+
+interface MediaRecord {
+ hash: string;
+ content_type: string;
+ kind: "image" | "video";
+ size: number;
+ created_at: string;
+ url: string;
+}
+
+/// Module-scoped constants
+
+const PROJECT_TYPES: Record = {
+ independent: { label: "Independent", colorClass: "[--type-color:var(--foreground)]" },
+ sig_swe: { label: "SWE", colorClass: "[--type-color:var(--sig-swe)]" },
+ sig_ai: { label: "AI", colorClass: "[--type-color:var(--sig-ai)]" },
+ sig_cyber: { label: "Cyber", colorClass: "[--type-color:var(--sig-cyber)]" },
+ sig_data: { label: "Data", colorClass: "[--type-color:var(--sig-data)]" },
+ sig_graph: { label: "Graph", colorClass: "[--type-color:var(--sig-graph)]" },
+ sig_arch: { label: "Arch", colorClass: "[--type-color:var(--sig-arch)]" },
+};
+
+const API_BASE_URL =
+ (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000";
+
+const CAROUSEL_OPTS = { align: "start" } as const;
+
+const FULL_DATE_FMT = new Intl.DateTimeFormat("en-US", { year: "numeric", month: "long" });
+
+/// Tanstack Query keys
+
+const projectKeys = {
+ all: ["project"] as const,
+ detail: (projectId: string) => [...projectKeys.all, projectId, "detail"] as const,
+ media: (projectId: string) => [...projectKeys.all, projectId, "media"] as const,
+};
+
+const projectDetailQueryOptions = (projectId: string) =>
+ queryOptions({
+ queryKey: projectKeys.detail(projectId),
+ queryFn: async () => {
+ const { data } = await axios.get(`${API_BASE_URL}/projects/${projectId}`);
+ return data;
+ },
+ staleTime: 60_000,
+
+ // Once Kanae is live, then these will be removed
+ // So we don't send out a ton of API requests for no reason
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+
+const projectMediaQueryOptions = (projectId: string) =>
+ queryOptions({
+ queryKey: projectKeys.media(projectId),
+ queryFn: async () => {
+ const { data } = await axios.get(
+ `${API_BASE_URL}/projects/${projectId}/media`,
+ );
+ return data;
+ },
+ staleTime: 60_000,
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ });
+
+/// Route Component
+
+function Project() {
+ const { projectId } = Route.useParams();
+ const { data: project, isLoading: isProjectLoading } = useQuery(
+ projectDetailQueryOptions(projectId),
+ );
+ const { data: media, isLoading: isMediaLoading } = useQuery(projectMediaQueryOptions(projectId));
+
+ const [openMedia, setOpenMedia] = useState();
+
+ const mediaEntries = useMemo(
+ () =>
+ (media ?? []).map((item) => ({
+ item,
+ onSelect: () => {
+ setOpenMedia(item);
+ },
+ })),
+ [media],
+ );
+
+ const handleMediaDialogOpenChange = useCallback((open: boolean) => {
+ if (!open) setOpenMedia(undefined);
+ }, []);
+
+ const meta = project ? PROJECT_TYPES[project.type] : undefined;
+ const showMediaSection = isMediaLoading || mediaEntries.length > 0;
+
+ return (
+
+
+
+
+ Back to Projects
+
+
+
+
+ {isProjectLoading && (
+
+ )}
+
+ {!isProjectLoading && (!project || !meta) && (
+
+ Project not found.
+
+ )}
+
+ {!isProjectLoading && project && meta && (
+ <>
+
+
+
+ {project.name}
+
+
+ {project.tags && project.tags.length > 0 ? (
+
+ {project.tags.map((tag) => (
+ -
+ {tag}
+
+ ))}
+
+ ) : undefined}
+
+
+
+
+
+ Description
+
+
+ {project.description}
+
+
+
+ {showMediaSection ? (
+
+
+
+
+ {isMediaLoading
+ ? Array.from({ length: 3 }, (_, i) => (
+
+
+
+ ))
+ : mediaEntries.map(({ item, onSelect }) => (
+
+ {item.kind === "video" ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+ ) : undefined}
+
+
+ >
+ )}
+
+
+
+
+ );
+}
diff --git a/src/routes/projects.tsx b/src/routes/projects.tsx
new file mode 100644
index 00000000..eb8669d0
--- /dev/null
+++ b/src/routes/projects.tsx
@@ -0,0 +1,434 @@
+import { queryOptions, useQuery } from "@tanstack/react-query";
+import { createFileRoute, Link } from "@tanstack/react-router";
+import axios from "axios";
+import { Search } from "lucide-react";
+import { useCallback, useMemo, useState, type ChangeEvent } from "react";
+
+import { Avatar, AvatarFallback } from "@/components/ui/avatar";
+import { Button } from "@/components/ui/button";
+import { Card } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Skeleton } from "@/components/ui/skeleton";
+import { cn } from "@/lib/utils";
+
+export const Route = createFileRoute("/projects")({
+ component: Projects,
+ loader: ({ context: { queryClient } }) => queryClient.prefetchQuery(projectsQueryOptions),
+});
+
+/// Types and Interfaces
+
+type ProjectType =
+ | "independent"
+ | "sig_ai"
+ | "sig_swe"
+ | "sig_cyber"
+ | "sig_data"
+ | "sig_arch"
+ | "sig_graph";
+
+type FilterKey = "all" | ProjectType;
+type ActiveFilter = "all" | "active" | "archived";
+
+interface ProjectMember {
+ id: string;
+ name: string;
+}
+
+interface ProjectThumbnail {
+ hash: string;
+ url: string;
+}
+
+interface ApiProject {
+ id: string;
+ name: string;
+ description: string;
+ link: string;
+ thumbnail?: ProjectThumbnail;
+ members: ProjectMember[];
+ type: ProjectType;
+ tags?: string[];
+ active: boolean;
+ founded_at: string;
+}
+
+interface ProjectsPage {
+ data: ApiProject[];
+ total: number;
+}
+
+/// Module-scoped constants
+
+const ACTIVE_FILTER_OPTIONS: readonly { value: ActiveFilter; label: string }[] = [
+ { value: "all", label: "All" },
+ { value: "active", label: "● Active" },
+ { value: "archived", label: "○ Archived" },
+];
+
+const PROJECT_TYPES = [
+ { key: "all", label: "All Projects", colorClass: "[--type-color:var(--foreground)]" },
+ { key: "independent", label: "Independent", colorClass: "[--type-color:var(--foreground)]" },
+ { key: "sig_swe", label: "SWE", colorClass: "[--type-color:var(--sig-swe)]" },
+ { key: "sig_ai", label: "AI", colorClass: "[--type-color:var(--sig-ai)]" },
+ { key: "sig_cyber", label: "Cyber", colorClass: "[--type-color:var(--sig-cyber)]" },
+ { key: "sig_data", label: "Data", colorClass: "[--type-color:var(--sig-data)]" },
+ { key: "sig_graph", label: "Graph", colorClass: "[--type-color:var(--sig-graph)]" },
+ { key: "sig_arch", label: "Arch", colorClass: "[--type-color:var(--sig-arch)]" },
+] as const satisfies readonly {
+ readonly key: FilterKey;
+ readonly label: string;
+ readonly colorClass: string;
+}[];
+
+const API_BASE_URL =
+ (import.meta.env.VITE_API_URL as string | undefined) ?? "http://localhost:8000";
+
+const SHORT_DATE_FMT = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric" });
+
+const GRID_CLASSES = cn("grid grid-cols-1 gap-3.5", "md:grid-cols-2 md:gap-5 lg:grid-cols-3");
+const CARD_VISUAL_CLASSES = cn(
+ "gap-3.5 rounded-3xl py-0",
+ "shadow-[0px_16px_40px_rgba(112,144,176,0.2)]",
+);
+
+/// Tanstack Query keys
+
+const projectsKeys = {
+ all: ["projects"] as const,
+ lists: () => [...projectsKeys.all, "list"] as const,
+ list: (params: Record) => [...projectsKeys.lists(), params] as const,
+};
+
+const projectsQueryOptions = queryOptions({
+ queryKey: projectsKeys.list({}),
+ queryFn: async () => {
+ const { data } = await axios.get(`${API_BASE_URL}/projects`);
+ return data;
+ },
+ staleTime: 60_000,
+
+ // Once Kanae is live, then these will be removed
+ // So we don't send out a ton of API requests for no reason
+ retry: false,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+});
+
+/// Route Component
+
+function Projects() {
+ const { data: projectsPage, isLoading } = useQuery(projectsQueryOptions);
+ const projects = projectsPage?.data;
+
+ const [filter, setFilter] = useState("all");
+ const [activeFilter, setActiveFilter] = useState("active");
+ const [search, setSearch] = useState("");
+
+ const filtered = useMemo(() => {
+ const query = search.trim().toLowerCase();
+ return (projects ?? [])
+ .filter(
+ (project) =>
+ (filter === "all" || project.type === filter) &&
+ (activeFilter === "all" || project.active === (activeFilter === "active")) &&
+ (!query ||
+ project.name.toLowerCase().includes(query) ||
+ project.description.toLowerCase().includes(query) ||
+ (project.tags?.some((t) => t.toLowerCase().includes(query)) ?? false)),
+ )
+ .map((project) => ({
+ project,
+ linkParams: { projectId: project.id },
+ }));
+ }, [projects, filter, activeFilter, search]);
+
+ const filterEntries = useMemo(
+ () =>
+ PROJECT_TYPES.map((option) => ({
+ option,
+ onSelect: () => {
+ setFilter(option.key);
+ },
+ })),
+ [],
+ );
+
+ const handleSearchChange = useCallback((event: ChangeEvent) => {
+ setSearch(event.target.value);
+ }, []);
+
+ const handleActiveFilterChange = useCallback((value: unknown) => {
+ if (value === "all" || value === "active" || value === "archived") {
+ setActiveFilter(value);
+ }
+ }, []);
+
+ const hasResults = filtered.length > 0;
+ const projectCountLabel = `${String(filtered.length)} ${filtered.length === 1 ? "project" : "projects"} found`;
+
+ return (
+
+
+
+
+
+
+ Project Showcase
+
+
+ Explore what our members are building across every SIG.
+
+
+
+
+
+
+
+ {filterEntries.map(({ option, onSelect }) => {
+ const isActive = filter === option.key;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isLoading ? "Loading projects…" : projectCountLabel}
+
+
+ {isLoading && (
+
+ {Array.from({ length: 6 }, (_, i) => (
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+ )}
+
+ {!isLoading && !hasResults && (
+
+ No projects exists
+
+ )}
+
+ {!isLoading && hasResults && (
+
+ {filtered.map(({ project, linkParams }) => {
+ const meta =
+ PROJECT_TYPES.find((type) => type.key === project.type) ?? PROJECT_TYPES[0];
+ const extraMembers = project.members.length - 3;
+ return (
+
+
+
+ {project.thumbnail ? (
+

+ ) : undefined}
+
+ {project.active ? "● Active" : "○ Archived"}
+
+
+
+
+
+ {meta.label}
+
+
+
+ {project.name}
+
+
+
+ {project.description}
+
+
+ {project.tags && project.tags.length > 0 ? (
+
+ {project.tags.slice(0, 3).map((tag) => (
+
+ {tag}
+
+ ))}
+
+ ) : undefined}
+
+
+
+ {project.members.slice(0, 3).map((member, memberIndex) => (
+
0 && "-ml-2",
+ )}
+ >
+
+ {member.name.charAt(0)}
+
+
+ ))}
+ {extraMembers > 0 ? (
+
+ +{extraMembers}
+
+ ) : undefined}
+
+
+ {SHORT_DATE_FMT.format(new Date(project.founded_at))}
+
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}