diff --git a/.gitignore b/.gitignore index bb3c59af..bf01e128 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,4 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? +*.sw? \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json index 3a394c77..de243ef9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,9 +24,7 @@ ], "overrides": [ { - "files": [ - "**/*.{ts,tsx}" - ], + "files": ["**/*.{ts,tsx}"], "rules": { "constructor-super": "error", "for-direction": "error", @@ -179,10 +177,7 @@ "allowRegExp": false } ], - "typescript/return-await": [ - "error", - "error-handling-correctness-only" - ], + "typescript/return-await": ["error", "error-handling-correctness-only"], "typescript/triple-slash-reference": "error", "typescript/unbound-method": "error", "typescript/unified-signatures": "error", @@ -283,9 +278,7 @@ "error", { "tags": [], - "roles": [ - "tabpanel" - ], + "roles": ["tabpanel"], "allowExpressionValues": true } ], @@ -322,7 +315,12 @@ "unicorn/error-message": "error", "unicorn/escape-case": "error", "unicorn/explicit-length-check": "error", - "unicorn/filename-case": "error", + "unicorn/filename-case": [ + "error", + { + "ignore": "\\$.*[A-Z]" + } + ], "unicorn/new-for-builtins": "error", "unicorn/no-abusive-eslint-disable": "error", "unicorn/no-accessor-recursion": "error", @@ -448,14 +446,7 @@ "@tanstack/router/create-route-property-order": "warn", "@tanstack/router/route-param-names": "error" }, - "plugins": [ - "typescript", - "react", - "import", - "react-perf", - "jsx-a11y", - "unicorn" - ], + "plugins": ["typescript", "react", "import", "react-perf", "jsx-a11y", "unicorn"], "jsPlugins": [ "eslint-plugin-react-x", "eslint-plugin-react-dom", @@ -471,4 +462,4 @@ } } ] -} \ No newline at end of file +} diff --git a/src/assets/images/sig-card-1.png b/src/assets/images/sig-card-1.png new file mode 100644 index 00000000..78df0941 Binary files /dev/null and b/src/assets/images/sig-card-1.png differ diff --git a/src/assets/images/sig-card-2.png b/src/assets/images/sig-card-2.png new file mode 100644 index 00000000..6879ab11 Binary files /dev/null and b/src/assets/images/sig-card-2.png differ diff --git a/src/assets/images/sig-card-3.png b/src/assets/images/sig-card-3.png new file mode 100644 index 00000000..cfbf9aa2 Binary files /dev/null and b/src/assets/images/sig-card-3.png differ diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 00000000..e92a2f48 --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: AvatarPrimitive.Root.Props & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: AvatarPrimitive.Fallback.Props) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
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 && ( + <> +
+
+ + {meta.label} + + + + +
+
+
    + {project.members.map((member) => ( +
  • + + + {member.name} + +
  • + ))} +
+
+
+ +

+ {project.name} +

+ + {project.tags && project.tags.length > 0 ? ( +
    + {project.tags.map((tag) => ( +
  • + {tag} +
  • + ))} +
+ ) : undefined} + +
+ +
+
+ Description +
+

+ {project.description} +

+
+ + {showMediaSection ? ( +
+ +
+
+ Media +
+
+ + +
+
+ + {isMediaLoading + ? Array.from({ length: 3 }, (_, i) => ( + + + + )) + : mediaEntries.map(({ item, onSelect }) => ( + + {item.kind === "video" ? ( +
+ +
+ + Video +
+ +
+ ) : ( + + )} +
+ ))} +
+
+
+ ) : undefined} + +
+ Founded {FULL_DATE_FMT.format(new Date(project.founded_at))} +
+ + )} +
+ + + {openMedia ? ( + + Media preview + {openMedia.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) => ( + + ))} + {extraMembers > 0 ? ( + + +{extraMembers} + + ) : undefined} +
+ + {SHORT_DATE_FMT.format(new Date(project.founded_at))} + +
+
+
+ + ); + })} +
+ )} +
+
+ ); +}