Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 33 additions & 12 deletions apps/web/src/app/(main)/dashboard/oss-programs/ProgramsList.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"use client";

import { useState, useMemo, useCallback } from "react";
import { useState, useMemo, useCallback, useEffect } from "react";
import { Program } from "@/data/oss-programs/types";
import { SearchInput, TagFilter, ProgramCard } from "@/components/oss-programs";
import { SearchInput, TagFilter, ProgramCard, ProgramCardSkeleton } from "@/components/oss-programs";

interface ProgramsListProps {
programs: Program[];
Expand All @@ -12,22 +12,36 @@ interface ProgramsListProps {
export default function ProgramsList({ programs, tags }: ProgramsListProps) {
const [searchQuery, setSearchQuery] = useState("");
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);

// Memoize handlers to prevent child re-renders
// Memoized handlers
const handleSearchChange = useCallback((value: string) => {
setIsLoading(true);
setSearchQuery(value);
}, []);

const handleTagsChange = useCallback((newTags: string[]) => {
setIsLoading(true);
setSelectedTags(newTags);
}, []);

// Fake loading delay for smooth UX
useEffect(() => {
if (!isLoading) return;

const timer = setTimeout(() => {
setIsLoading(false);
}, 200); // skeleton visible time

return () => clearTimeout(timer);
}, [isLoading]);

const filteredPrograms = useMemo(() => {
return programs.filter((program) => {
const matchesSearch =
program.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
program.tags.some((tag) =>
tag.toLowerCase().includes(searchQuery.toLowerCase())
tag.toLowerCase().includes(searchQuery.toLowerCase()),
);

const matchesTags =
Expand All @@ -41,9 +55,9 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
return (
<div className="min-h-full w-[99vw] lg:w-[80vw] bg-dash-base text-white p-4 md:p-8 lg:p-12 overflow-x-hidden">
<div className="max-w-6xl mx-auto w-full min-w-0">
{/* Header Section */}
{/* Header */}
<div className="flex flex-col gap-8 mb-12 min-w-0">
<h1 className="text-3xl md:text-4xl font-bold text-text-primary break-words">
<h1 className="text-3xl md:text-4xl font-bold text-text-primary">
OSS Programs
</h1>

Expand All @@ -61,19 +75,26 @@ export default function ProgramsList({ programs, tags }: ProgramsListProps) {
</div>
</div>

{/* List Section */}
<div className="flex flex-col gap-2 md:gap-3 min-w-0">
{filteredPrograms.length === 0 ? (
{/* List */}
<div className="flex flex-col gap-3 min-w-0">
{isLoading &&
Array.from({ length: 3 }).map((_, i) => (
<ProgramCardSkeleton key={`skeleton-${i}`} />
))}

{!isLoading && filteredPrograms.length === 0 && (
<div className="text-center py-20 text-text-muted">
No programs found matching your criteria.
</div>
) : (
)}

{!isLoading &&
filteredPrograms.map((program) => (
<ProgramCard key={program.slug} program={program} />
))
)}
))}
</div>
</div>
</div>
);
}

31 changes: 31 additions & 0 deletions apps/web/src/components/oss-programs/ProgramCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import React from "react";

function ProgramCardSkeleton() {
return (
<div className="w-full bg-dash-surface border border-dash-border rounded-xl px-4 py-3 md:px-5 md:py-4 flex items-center justify-between gap-3 md:gap-4 animate-pulse">
{/* Left Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<div className="h-4 md:h-5 w-1/3 bg-gray-700/50 rounded mb-2" />

{/* Description */}
<div className="h-3 md:h-4 w-2/3 bg-gray-700/40 rounded" />
</div>

{/* Region (desktop only) */}
<div className="hidden md:flex items-center gap-6 flex-shrink-0">
<div className="text-right">
<div className="h-3 w-10 bg-gray-700/40 rounded mb-1 ml-auto" />
<div className="h-4 w-14 bg-gray-700/50 rounded ml-auto" />
</div>
</div>

{/* Chevron */}
<div className="flex-shrink-0">
<div className="w-4 h-4 md:w-5 md:h-5 bg-gray-700/40 rounded-full" />
</div>
</div>
);
}

export default React.memo(ProgramCardSkeleton);
72 changes: 60 additions & 12 deletions apps/web/src/components/oss-programs/TagFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ export default function TagFilter({
}: TagFilterProps) {
const [filterInput, setFilterInput] = useState("");
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);

const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (!isDropdownOpen) return; // Only attach listener when open
if (!isDropdownOpen) return;

function handleClickOutside(event: MouseEvent) {
if (
Expand All @@ -31,11 +33,11 @@ export default function TagFilter({
}
}

// Use passive listener for better scroll performance
document.addEventListener("mousedown", handleClickOutside, {
passive: true,
});
return () => document.removeEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}, [isDropdownOpen]);

const availableTags = useMemo(() => {
Expand All @@ -46,10 +48,16 @@ export default function TagFilter({
);
}, [tags, selectedTags, filterInput]);

// Reset active suggestion on change
useEffect(() => {
setActiveIndex(0);
}, [filterInput, availableTags.length]);

const addTag = (tag: string) => {
onTagsChange([...selectedTags, tag]);
setFilterInput("");
setIsDropdownOpen(true);
setActiveIndex(0);
inputRef.current?.focus();
};

Expand All @@ -58,6 +66,27 @@ export default function TagFilter({
};

const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isDropdownOpen || availableTags.length === 0) return;

if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((prev) =>
prev < availableTags.length - 1 ? prev + 1 : 0
);
}

if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((prev) =>
prev > 0 ? prev - 1 : availableTags.length - 1
);
}

if (e.key === "Enter") {
e.preventDefault();
addTag(availableTags[activeIndex]);
}

if (
e.key === "Backspace" &&
filterInput === "" &&
Expand All @@ -69,8 +98,11 @@ export default function TagFilter({

return (
<div className="relative flex-1 min-w-0" ref={dropdownRef}>
{/* Input Container */}
<div
className="flex items-center gap-2 bg-dash-surface border border-dash-border rounded-xl p-2 min-h-[50px] focus-within:border-brand-purple transition-colors cursor-text min-w-0"
className="flex items-center gap-2 bg-dash-surface border border-dash-border rounded-xl
px-3 py-2.5 min-h-[52px]
focus-within:border-brand-purple transition-colors cursor-text min-w-0"
onClick={() => {
inputRef.current?.focus();
setIsDropdownOpen(true);
Expand All @@ -80,7 +112,8 @@ export default function TagFilter({
{selectedTags.map((tag) => (
<span
key={tag}
className="flex items-center gap-1 bg-brand-purple/20 text-brand-purple-light px-3 py-1 rounded-full text-sm flex-shrink-0"
className="flex items-center gap-1.5 bg-brand-purple/20 text-brand-purple-light
px-3 py-1.5 rounded-full text-sm flex-shrink-0"
>
{tag}
<button
Expand All @@ -89,12 +122,13 @@ export default function TagFilter({
removeTag(tag);
}}
aria-label={`Remove ${tag}`}
className="hover:text-white"
className="hover:text-white transition-colors"
>
<X className="w-3 h-3" />
</button>
</span>
))}

<input
ref={inputRef}
type="text"
Expand All @@ -106,26 +140,40 @@ export default function TagFilter({
}}
onKeyDown={handleKeyDown}
onFocus={() => setIsDropdownOpen(true)}
className="bg-transparent text-white placeholder-gray-500 focus:outline-none flex-1 min-w-0"
className="bg-transparent text-white placeholder-gray-500
focus:outline-none flex-1 min-w-[120px]"
/>
</div>
<ChevronDown className="w-5 h-5 text-gray-400 flex-shrink-0" />

<ChevronDown className="w-5 h-5 text-gray-400 flex-shrink-0 transition-transform duration-200" />
</div>

{/* Replaced Framer Motion with CSS transitions for better performance */}
{/* Dropdown */}
{isDropdownOpen && (
<div className="absolute z-20 top-full left-0 right-0 mt-2 bg-dash-surface border border-dash-border rounded-xl shadow-xl max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-2 duration-150">
<div
className="absolute z-20 top-full left-0 right-0 mt-2
bg-dash-surface border border-dash-border rounded-xl shadow-xl
max-h-60 overflow-y-auto
animate-in fade-in slide-in-from-top-2 duration-150
scroll-smooth overscroll-contain"
>
{availableTags.length === 0 ? (
<div className="p-4 text-gray-500 text-center">
No matching tags found
</div>
) : (
availableTags.map((tag) => (
availableTags.map((tag, index) => (
<button
key={tag}
onClick={() => addTag(tag)}
aria-label={`Add tag ${tag}`}
className="w-full text-left px-4 py-3 hover:bg-dash-hover text-gray-300 hover:text-white transition-colors flex items-center justify-between"
className={`w-full text-left px-4 py-2.5
transition-colors flex items-center justify-between
${
index === activeIndex
? "bg-dash-hover text-white"
: "text-gray-300 hover:bg-dash-hover hover:text-white"
}`}
>
{tag}
</button>
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/oss-programs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export { default as ProgramCard } from './ProgramCard';
export { default as ProgramHeader } from './ProgramHeader';
export { default as ProgramMetadata } from './ProgramMetadata';
export { default as ProgramSection } from './ProgramSection';
export { default as ProgramCardSkeleton} from './ProgramCardSkeleton'