Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
e63cf40
feat: sort projects alphabetically
maryLLay Jul 20, 2026
bfd40f0
feat: add option to sort by last update
maryLLay Aug 3, 2026
651df17
Chore: adjust filter menu font
maryLLay Aug 28, 2026
46ca16a
Merge remote-tracking branch 'origin/main' into sorting_options
allison-truhlar Sep 14, 2026
92181c3
feat: move sort options into their own SortMenu component
allison-truhlar Sep 14, 2026
ced0d05
fix: align filter menu spacing and show its title on all screens
allison-truhlar Sep 14, 2026
c10f066
Merge remote-tracking branch 'upstream/main' into sorting_options
maryLLay Oct 5, 2026
6203ab0
Chore: update package-lock
maryLLay Oct 5, 2026
889e549
perf: pass per-card sortOrder instead of full sortedTitlesMap to Card…
maryLLay Oct 9, 2026
0406991
chore: remove unused repo-dates.json from update-project-dates workflow
maryLLay Oct 9, 2026
6e320b9
fix: tolerate partial GraphQL results in update-project-dates
maryLLay Oct 12, 2026
de4c3c9
fix: trigger deploy after bot commits updated project dates
maryLLay Oct 12, 2026
754205c
Merge remote-tracking branch 'upstream/main' into sorting_options
maryLLay Oct 12, 2026
d26162a
fix: add update date to project md
maryLLay Oct 15, 2026
2fa97bd
fix: correct update_date field in projects content schema
maryLLay Oct 15, 2026
8c4189c
fix: use grid layout at all breakpoints so card sort order applies on…
maryLLay Oct 15, 2026
0f43aac
refactor: fetch project update dates at build time
allison-truhlar Nov 4, 2026
96b870e
chore: update dependencies via npm audit fix
maryLLay Nov 6, 2026
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
29 changes: 29 additions & 0 deletions .github/workflows/scheduled-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Scheduled deploy

# Rebuilds the site weekly so the "Last updated" sort picks up new commits in
# project repos. Kept separate from deploy-to-github-pages.yml because GitHub
# disables scheduled workflows after 60 days without repo activity; if that
# happens here, deploys on push to main keep working.
on:
schedule:
- cron: "0 6 * * 1" # every Monday at 06:00 UTC
workflow_dispatch:

permissions:
actions: write

jobs:
trigger-deploy:
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
steps:
- name: Trigger deploy
run: gh workflow run deploy-to-github-pages.yml

# Re-enabling an already-enabled workflow resets the 60-day inactivity
# timer, so this schedule survives long stretches without commits.
- name: Keep schedule alive
if: always()
run: gh workflow enable scheduled-deploy.yml
2,887 changes: 910 additions & 1,977 deletions package-lock.json

Large diffs are not rendered by default.

14 changes: 12 additions & 2 deletions src/components/projects/CardContainer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
import { useStore } from "@nanostores/react";
import { selectedTags } from "@stores/selectedTagsStore";
import { selectedProjectType } from "@stores/selectedProjectTypeStore";
import { selectedSort } from "@stores/selectedSortStore";
import { getTagValues } from "@utils/getTags";
import { $projectData } from "@stores/projectSearchResultsStore";
import { $ecosystemData } from "@stores/ecosystemSearchResultsStore";
Expand All @@ -17,6 +18,7 @@ export default function CardContainer({
cardContent,
contentType,
maxLength,
sortOrder,
}) {
const [visible, setVisible] = useState("relative");
const [order, setOrder] = useState(0);
Expand All @@ -25,6 +27,7 @@ export default function CardContainer({
const ecosystemData = useStore($ecosystemData);
const $selectedTags = useStore(selectedTags);
const $selectedProjectType = useStore(selectedProjectType);
const $selectedSort = useStore(selectedSort);
const tagsArray = getTagValues(tagsObj);

let contentData = null;
Expand Down Expand Up @@ -53,8 +56,14 @@ export default function CardContainer({
useEffect(() => {
if (typeof window !== "undefined") {
const matchingIndex = findMatchingIndex(contentData, title);
const itemOrder =
urlQuery === "" ? Math.floor(Math.random() * maxLength) : matchingIndex;
let itemOrder;
if (urlQuery !== "") {
itemOrder = matchingIndex;
} else if (sortOrder && Number.isInteger(sortOrder[$selectedSort])) {
itemOrder = sortOrder[$selectedSort];
} else {
itemOrder = Math.floor(Math.random() * maxLength);
}
setOrder(itemOrder);

const isSearchMatch =
Expand Down Expand Up @@ -83,6 +92,7 @@ export default function CardContainer({
contentData,
$selectedProjectType,
$selectedTags,
$selectedSort,
contentType,
projectType,
]);
Expand Down
15 changes: 5 additions & 10 deletions src/components/projects/FilterMenu.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export default function FilterMenu({ uniqueTags }) {
<div
className={`${
$isFilterMenuVisible ? "flex translate-x-0" : "translate-x-full"
} bg-page z-40 md:z-auto fixed md:static md:translate-x-0 top-0 right-0 transition-transform duration-500 w-full h-[100dvh] md:h-auto md:max-h-full flex-col px-4 md:pl-0 `}
} bg-page z-40 md:z-auto fixed md:static md:translate-x-0 top-0 right-0 transition-transform duration-500 w-full h-[100dvh] md:h-auto md:max-h-full flex-col px-4 md:pl-0 md:mb-4`}
>
<button
className="md:hidden z-50 my-4 self-end btn-secondary rounded-full"
Expand All @@ -60,8 +60,7 @@ export default function FilterMenu({ uniqueTags }) {
<TbX />
</button>
<div className="overflow-y-scroll md:overflow-hidden px-2">
{/* This h3 and button are visible only on medium and larger screen sizes */}
<div className="hidden md:flex items-center justify-between pt-2 pb-4">
<div className="flex items-center justify-between pt-2 md:pt-0 pb-4">
<h3 className="text-lg font-bold ">Filter by tag</h3>
<button
className="btn-reset"
Expand Down Expand Up @@ -120,15 +119,11 @@ export default function FilterMenu({ uniqueTags }) {
)}
</div>

<div className="flex self-center gap-4 py-4">
<button className="btn-primary md:hidden" onClick={toggleVisibility}>
<div className="md:hidden flex self-center gap-4 py-4">
<button className="btn-primary" onClick={toggleVisibility}>
View projects
</button>
{/* This reset button is visible on small screens */}
<button
className="btn md:hidden"
onClick={() => handleTagSelection(null)}
>
<button className="btn" onClick={() => handleTagSelection(null)}>
Reset
</button>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/projects/ProjectTypeDropdown.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const getOptionColorClasses = ({ selected, highlighted, disabled }) => {
return classes;
};

const Option = React.forwardRef((props, ref) => {
export const Option = React.forwardRef((props, ref) => {
return (
<BaseOption
ref={ref}
Expand Down Expand Up @@ -97,7 +97,7 @@ const resolveSlotProps = (fn, args) =>
typeof fn === "function" ? fn(args) : fn;

// I added the prop "multiple" to allow for multiple selections
const Select = React.forwardRef(function CustomSelect(props, ref) {
export const Select = React.forwardRef(function CustomSelect(props, ref) {
return (
<BaseSelect
ref={ref}
Expand Down
62 changes: 62 additions & 0 deletions src/components/projects/SortMenu.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useStore } from "@nanostores/react";
import { selectedSort, handleSortSelection } from "@stores/selectedSortStore";
import { Select, Option } from "@components/projects/ProjectTypeDropdown";

const SORT_OPTIONS = [
{ key: "alphabetical", label: "Alphabetical (A–Z)" },
{ key: "updated-newest", label: "Last updated (newest first)" },
{ key: "updated-oldest", label: "Last updated (oldest first)" },
];

// Small screens: title + dropdown on one row (the "No sorting" option acts as reset).
// md+: title + Reset button, then a list of options laid out like FilterMenu.
export default function SortMenu() {
const $selectedSort = useStore(selectedSort);

return (
<div className="flex-1 min-w-0 flex items-center gap-2 mb-8 md:flex-none md:block md:pl-2 md:pr-6">
<h3 className="font-bold whitespace-nowrap md:hidden">Sort by</h3>
<div className="hidden md:flex items-center justify-between pb-4">
<h3 className="text-lg font-bold">Sort by</h3>
<button className="btn-reset" onClick={() => handleSortSelection("")}>
Reset
</button>
</div>

<div className="md:hidden flex-1 min-w-0">
<Select
slotProps={{ root: { className: "w-full whitespace-nowrap truncate pr-8" } }}
value={$selectedSort}
onChange={(_, value) => handleSortSelection(value ?? "")}
>
<Option value="" className="cursor-pointer my-1">
No sorting
</Option>
{SORT_OPTIONS.map(({ key, label }) => (
<Option key={key} value={key} className="cursor-pointer my-1">
{label}
</Option>
))}
</Select>
</div>

<ul className="hidden md:flex flex-col flex-nowrap">
{SORT_OPTIONS.map(({ key, label }) => (
<li
key={key}
className="w-full flex cursor-pointer"
onClick={() => handleSortSelection(key)}
>
<div
className={`ml-2 self-start my-1 ${
$selectedSort === key ? "selected bg-primary" : ""
}`}
>
{label}
</div>
</li>
))}
</ul>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/projects/ToggleFilterMenuBtn.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default function ToggleFilterMenuBtn() {
>
{numFilters}
</Badge>
<p className="text-sm">Filter by tag</p>
<p className="text-sm whitespace-nowrap">Filter by tag</p>
<TbAdjustmentsHorizontal className="w-5 h-5 inline-block" />
</button>
);
Expand Down
47 changes: 45 additions & 2 deletions src/pages/projects/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,49 @@ import ProjectCount from "@components/projects/ProjectCount";
import ProjectTypeBtns from "@components/projects/ProjectTypeBtns";
import ProjectTypeDropdown from "@components/projects/ProjectTypeDropdown";
import SearchInput from "@components/projects/SearchInput";
import SortMenu from "@components/projects/SortMenu";
import Tag from "@components/projects/Tag.astro";
import ToggleFilterMenuBtn from "@components/projects/ToggleFilterMenuBtn";

import { getTagKeysAndValues } from "@utils/getTags";
import { getRepoUpdateDates } from "@utils/githubApiHelper";

const pageTitle = "OSSI-supported projects";
const allProjects = await getCollection("projects");

// uniqueTags is an object where keys = unique tag categories, values = unique tags within a category, both across allProjects. All lowercase.
// used to populate the filter menu
const uniqueTags = getTagKeysAndValues(allProjects);

// all project titles sorted alphabetically; used to position cards when the "Alphabetical" sort is selected
const sortedTitles = allProjects
.map((content) => content.data.title)
.sort((a, b) => a.localeCompare(b));

// last push to each project's repo, keyed by "github link";
// fetched at build time, so dates refresh on every deploy
const repoDates = await getRepoUpdateDates(
allProjects.map((content) => content.data["github link"])
);
const updateDateOf = (content) => repoDates[content.data["github link"]];

// project titles sorted by last update, oldest-to-newest; projects with no
// known update date are left at the end regardless of sort direction
const undatedTitles = allProjects
.filter((content) => !updateDateOf(content))
.map((content) => content.data.title);
const titlesByUpdateDateAsc = allProjects
.filter((content) => updateDateOf(content))
.sort((a, b) => updateDateOf(a) - updateDateOf(b))
.map((content) => content.data.title);

// maps each selectable "sort by" key to its pre-sorted array of titles;
// consumed by CardContainer to position each card via CSS order
const sortedTitlesMap = {
alphabetical: sortedTitles,
"updated-newest": [...titlesByUpdateDateAsc].reverse().concat(undatedTitles),
"updated-oldest": [...titlesByUpdateDateAsc, ...undatedTitles],
};
---

<BaseLayout pageTitle={pageTitle}>
Expand All @@ -41,8 +73,12 @@ const uniqueTags = getTagKeysAndValues(allProjects);
>
<div class="col-start-1 col-span-1">
<SearchInput client:load />
<ToggleFilterMenuBtn client:only="react" />
<FilterMenu uniqueTags={uniqueTags} client:only="react" />
<!-- small screens: filter button + sort select share a row; md+: unwrapped -->
<div class="flex items-center gap-4 md:contents">
<ToggleFilterMenuBtn client:only="react" />
<SortMenu client:only="react" />
</div>
</div>
<div class="col-start-2 col-span-2 3xl:col-span-3 4xl:col-span-4">
<ProjectTypeBtns client:only="react" />
Expand All @@ -59,11 +95,17 @@ const uniqueTags = getTagKeysAndValues(allProjects);
client:only="react"
/>
<div
class="lg:grid grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4 auto-rows-max gap-4"
class="grid grid-cols-1 lg:grid-cols-2 3xl:grid-cols-3 4xl:grid-cols-4 auto-rows-max gap-4"
>
{
allProjects.map((content) => {
const tagsObj = getTagKeysAndValues(content);
const title = content.data.title;
const sortOrder = {
alphabetical: sortedTitlesMap.alphabetical.indexOf(title),
"updated-newest": sortedTitlesMap["updated-newest"].indexOf(title),
"updated-oldest": sortedTitlesMap["updated-oldest"].indexOf(title),
};
return (
<CardContainer
key={content.id}
Expand All @@ -73,6 +115,7 @@ const uniqueTags = getTagKeysAndValues(allProjects);
projectType={content.data["project type"][0]}
contentType="projects"
maxLength={allProjects.length}
sortOrder={sortOrder}
client:only="react"
>
{content.data["image file"] ? (
Expand Down
39 changes: 39 additions & 0 deletions src/stores/selectedSortStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { atom, onMount } from "nanostores";

export const selectedSort = atom("");

onMount(selectedSort, () => {
const currentSort = selectedSort.get();
let urlSort = "";
if (typeof window !== "undefined") {
const searchParams = new URLSearchParams(window.location.search);
urlSort = searchParams.get("sort") || "";
}
if (currentSort === "" && urlSort !== "") {
selectedSort.set(urlSort);
}
});

function updateSortSearchParams(updatedSort) {
// update URL search params
if (typeof window !== "undefined") {
const currentUrl = new URL(window.location);
const searchParams = new URLSearchParams(currentUrl.search);

if (updatedSort) {
searchParams.set("sort", updatedSort);
} else {
searchParams.delete("sort");
}

// Use history.pushState to update the URL without reloading the page
window.history.pushState({}, "", `${currentUrl.pathname}?${searchParams}`);
}
}

export function handleSortSelection(sort) {
const prevSort = selectedSort.get();
const updatedSort = prevSort === sort ? "" : sort;
selectedSort.set(updatedSort);
updateSortSearchParams(updatedSort);
}
Loading
Loading