From 7f14bf2202d5e02139c2793d4a67bdadb756c53b Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 00:29:44 +0530 Subject: [PATCH 1/6] feat: add saved searches --- .../issues/components/issue-finder.tsx | 178 ++++++++++++++++-- src/features/issues/lib/saved-searches.ts | 98 ++++++++++ 2 files changed, 263 insertions(+), 13 deletions(-) create mode 100644 src/features/issues/lib/saved-searches.ts diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index be25489..3f89999 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -1,10 +1,16 @@ "use client"; -import { FormEvent, useMemo, useState } from "react"; -import { GitPullRequest, Search } from "lucide-react"; +import { FormEvent, useEffect, useMemo, useState } from "react"; +import { Bookmark, GitPullRequest, Search, Trash2 } from "lucide-react"; import { ThemeToggle } from "@/components/theme-toggle"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + addSavedSearch, + deleteSavedSearch, + getSavedSearches, + type SavedSearch, +} from "@/features/issues/lib/saved-searches"; import { Card, CardContent, @@ -48,6 +54,13 @@ export function IssueFinder() { const [error, setError] = useState(null); const [cooldown, setCooldown] = useState(false); + const [savedSearches, setSavedSearches] = useState([]); + const [savedSearchName, setSavedSearchName] = useState(""); + + useEffect(() => { + setSavedSearches(getSavedSearches()); + }, []); + const selectedLabel = useMemo( () => LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0], [label], @@ -73,13 +86,77 @@ export function IssueFinder() { return issues.length < data.candidateCount && data.issues.length === 24; }, [data, issues]); - async function searchIssues(event?: FormEvent) { - event?.preventDefault(); + function handleSaveSearch() { + const name = savedSearchName.trim(); - if (!tech.trim()) { - setError("Enter a technology to search."); - return; - } + if (!name) { + setError("Enter a name for the saved search."); + return; + } + + if (!tech.trim()) { + setError("Enter a technology before saving the search."); + return; + } + + const savedSearch = addSavedSearch({ + name, + tech: tech.trim(), + label, + sort, + linkedPr, + hacktoberfest, + }); + + setSavedSearches((current) => [...current, savedSearch]); + setSavedSearchName(""); + setError(null); +} + + function handleDeleteSavedSearch(id: string) { + deleteSavedSearch(id); + setSavedSearches(getSavedSearches()); + } + + function handleRunSavedSearch(savedSearch: SavedSearch) { + setTech(savedSearch.tech); + setLabel(savedSearch.label); + setSort(savedSearch.sort); + setLinkedPr(savedSearch.linkedPr); + setHacktoberfest(savedSearch.hacktoberfest); + + void searchIssues(undefined, { + tech: savedSearch.tech, + label: savedSearch.label, + sort: savedSearch.sort, + linkedPr: savedSearch.linkedPr, + hacktoberfest: savedSearch.hacktoberfest, + }); +} + + async function searchIssues( + event?: FormEvent, + searchOverride?: { + tech: string; + label: string; + sort: string; + linkedPr: string; + hacktoberfest: string; + }, +) { + event?.preventDefault(); + + const searchTech = searchOverride?.tech ?? tech; + const searchLabel = searchOverride?.label ?? label; + const searchSort = searchOverride?.sort ?? sort; + const searchLinkedPr = searchOverride?.linkedPr ?? linkedPr; + const searchHacktoberfest = + searchOverride?.hacktoberfest ?? hacktoberfest; + + if (!searchTech.trim()) { + setError("Enter a technology to search."); + return; + } setIsLoading(true); setCooldown(true); @@ -88,11 +165,11 @@ export function IssueFinder() { setPage(1); const params = new URLSearchParams({ - tech: tech.trim(), - label, - sort, - linkedPr, - hacktoberfest, + tech: searchTech.trim(), + label: searchLabel, + sort: searchSort, + linkedPr: searchLinkedPr, + hacktoberfest: searchHacktoberfest, }); try { @@ -329,6 +406,81 @@ export function IssueFinder() { })} + + + Saved searches + + Save your current filters and reuse them later. + + + + +
+ setSavedSearchName(event.target.value)} + placeholder="Search name" + aria-label="Saved search name" + /> + + +
+ + {savedSearches.length === 0 ? ( +

+ No saved searches yet. +

+ ) : ( +
+ {savedSearches.map((savedSearch) => ( +
+
+

+ {savedSearch.name} +

+ +

+ {savedSearch.tech} · {savedSearch.label} +

+
+ +
+ + + +
+
+ ))} +
+ )} +
+
diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts new file mode 100644 index 0000000..c2c0ec7 --- /dev/null +++ b/src/features/issues/lib/saved-searches.ts @@ -0,0 +1,98 @@ +import { + HACKTOBERFEST_FILTERS, + LABEL_OPTIONS, + LINKED_PR_FILTERS, + SORT_OPTIONS, +} from "@/features/issues/data/search-options"; + +export type SavedSearch = { + id: string; + name: string; + tech: string; + label: string; + sort: string; + linkedPr: string; + hacktoberfest: string; + createdAt: string; +}; + +const STORAGE_KEY = "openissue:saved-searches"; + +function isValidSavedSearch(value: unknown): value is SavedSearch { + if (!value || typeof value !== "object") { + return false; + } + + const search = value as Partial; + + return ( + typeof search.id === "string" && + typeof search.name === "string" && + typeof search.tech === "string" && + typeof search.label === "string" && + typeof search.sort === "string" && + typeof search.linkedPr === "string" && + typeof search.hacktoberfest === "string" && + typeof search.createdAt === "string" && + LABEL_OPTIONS.some((option) => option.value === search.label) && + SORT_OPTIONS.some((option) => option.value === search.sort) && + LINKED_PR_FILTERS.has(search.linkedPr) && + HACKTOBERFEST_FILTERS.has(search.hacktoberfest) + ); +} + +export function getSavedSearches(): SavedSearch[] { + if (typeof window === "undefined") { + return []; + } + + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + + if (!stored) { + return []; + } + + const parsed: unknown = JSON.parse(stored); + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isValidSavedSearch); + } catch { + return []; + } +} + +function saveSavedSearches(searches: SavedSearch[]): void { + if (typeof window === "undefined") { + return; + } + + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(searches)); + } catch { + // Ignore storage failures so the search UI remains usable. + } +} + +export function addSavedSearch( + search: Omit, +): SavedSearch { + const savedSearch: SavedSearch = { + ...search, + id: crypto.randomUUID(), + createdAt: new Date().toISOString(), + }; + + const searches = getSavedSearches(); + saveSavedSearches([...searches, savedSearch]); + + return savedSearch; +} + +export function deleteSavedSearch(id: string): void { + const searches = getSavedSearches(); + saveSavedSearches(searches.filter((search) => search.id !== id)); +} \ No newline at end of file From 745885aeace4d28dc7c3718691e8d43c98c86237 Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 00:44:26 +0530 Subject: [PATCH 2/6] fix: address saved search review feedback --- .../issues/components/issue-finder.tsx | 336 ++++++++++-------- src/features/issues/lib/saved-searches.ts | 2 +- 2 files changed, 188 insertions(+), 150 deletions(-) diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 3f89999..57d3004 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -62,12 +62,14 @@ export function IssueFinder() { }, []); const selectedLabel = useMemo( - () => LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0], + () => + LABEL_OPTIONS.find((item) => item.value === label) ?? LABEL_OPTIONS[0], [label], ); const selectedLinkedPr = useMemo( () => - LINKED_PR_OPTIONS.find((item) => item.value === linkedPr) ?? LINKED_PR_OPTIONS[0], + LINKED_PR_OPTIONS.find((item) => item.value === linkedPr) ?? + LINKED_PR_OPTIONS[0], [linkedPr], ); const selectedSort = useMemo( @@ -87,31 +89,31 @@ export function IssueFinder() { }, [data, issues]); function handleSaveSearch() { - const name = savedSearchName.trim(); + const name = savedSearchName.trim(); - if (!name) { - setError("Enter a name for the saved search."); - return; - } + if (!name) { + setError("Enter a name for the saved search."); + return; + } - if (!tech.trim()) { - setError("Enter a technology before saving the search."); - return; - } + if (!tech.trim()) { + setError("Enter a technology before saving the search."); + return; + } - const savedSearch = addSavedSearch({ - name, - tech: tech.trim(), - label, - sort, - linkedPr, - hacktoberfest, - }); - - setSavedSearches((current) => [...current, savedSearch]); - setSavedSearchName(""); - setError(null); -} + const savedSearch = addSavedSearch({ + name, + tech: tech.trim(), + label, + sort, + linkedPr, + hacktoberfest, + }); + + setSavedSearches((current) => [...current, savedSearch]); + setSavedSearchName(""); + setError(null); + } function handleDeleteSavedSearch(id: string) { deleteSavedSearch(id); @@ -119,44 +121,43 @@ export function IssueFinder() { } function handleRunSavedSearch(savedSearch: SavedSearch) { - setTech(savedSearch.tech); - setLabel(savedSearch.label); - setSort(savedSearch.sort); - setLinkedPr(savedSearch.linkedPr); - setHacktoberfest(savedSearch.hacktoberfest); - - void searchIssues(undefined, { - tech: savedSearch.tech, - label: savedSearch.label, - sort: savedSearch.sort, - linkedPr: savedSearch.linkedPr, - hacktoberfest: savedSearch.hacktoberfest, - }); -} + setTech(savedSearch.tech); + setLabel(savedSearch.label); + setSort(savedSearch.sort); + setLinkedPr(savedSearch.linkedPr); + setHacktoberfest(savedSearch.hacktoberfest); + + void searchIssues(undefined, { + tech: savedSearch.tech, + label: savedSearch.label, + sort: savedSearch.sort, + linkedPr: savedSearch.linkedPr, + hacktoberfest: savedSearch.hacktoberfest, + }); + } async function searchIssues( - event?: FormEvent, - searchOverride?: { - tech: string; - label: string; - sort: string; - linkedPr: string; - hacktoberfest: string; - }, -) { - event?.preventDefault(); - - const searchTech = searchOverride?.tech ?? tech; - const searchLabel = searchOverride?.label ?? label; - const searchSort = searchOverride?.sort ?? sort; - const searchLinkedPr = searchOverride?.linkedPr ?? linkedPr; - const searchHacktoberfest = - searchOverride?.hacktoberfest ?? hacktoberfest; - - if (!searchTech.trim()) { - setError("Enter a technology to search."); - return; - } + event?: FormEvent, + searchOverride?: { + tech: string; + label: string; + sort: string; + linkedPr: string; + hacktoberfest: string; + }, + ) { + event?.preventDefault(); + + const searchTech = searchOverride?.tech ?? tech; + const searchLabel = searchOverride?.label ?? label; + const searchSort = searchOverride?.sort ?? sort; + const searchLinkedPr = searchOverride?.linkedPr ?? linkedPr; + const searchHacktoberfest = searchOverride?.hacktoberfest ?? hacktoberfest; + + if (!searchTech.trim()) { + setError("Enter a technology to search."); + return; + } setIsLoading(true); setCooldown(true); @@ -255,8 +256,9 @@ export function IssueFinder() { Find active open-source issues by tech.

- Search contributor-friendly GitHub issues with labels like help wanted, - good first issue, up-for-grabs, and documentation. + Search contributor-friendly GitHub issues with labels like + help wanted, good first issue, up-for-grabs, and + documentation.

@@ -277,7 +279,11 @@ export function IssueFinder() { setSavedSearchName(event.target.value)} - placeholder="Search name" - aria-label="Saved search name" - /> - - - - - {savedSearches.length === 0 ? ( -

- No saved searches yet. -

- ) : ( -
- {savedSearches.map((savedSearch) => ( -
-
-

- {savedSearch.name} -

- -

- {savedSearch.tech} · {savedSearch.label} -

-
+ + Saved searches + + Save your current filters and reuse them later. + + -
- + +
+ setSavedSearchName(event.target.value)} + placeholder="Search name" + aria-label="Saved search name" + /> - -
-
- ))} -
- )} - - + +
+ + {savedSearches.length === 0 ? ( +

+ No saved searches yet. +

+ ) : ( +
+ {savedSearches.map((savedSearch) => ( +
+
+

+ {savedSearch.name} +

+ +

+ {savedSearch.tech} · {savedSearch.label} +

+
+ +
+ + + +
+
+ ))} +
+ )} + +
{error ? ( - Search failed + + Search failed + {error} @@ -526,13 +562,15 @@ export function IssueFinder() { No matching issues - Try a broader technology, another label, or recently updated sorting. + Try a broader technology, another label, or recently updated + sorting. ) : null} - {!isLoading && issues.map((issue) => )} + {!isLoading && + issues.map((issue) => )} {!isLoading && hasMore && (
diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts index c2c0ec7..b10100a 100644 --- a/src/features/issues/lib/saved-searches.ts +++ b/src/features/issues/lib/saved-searches.ts @@ -95,4 +95,4 @@ export function addSavedSearch( export function deleteSavedSearch(id: string): void { const searches = getSavedSearches(); saveSavedSearches(searches.filter((search) => search.id !== id)); -} \ No newline at end of file +} From ca0d83df7b44bdec4011d21edd10fa8fd66a24a5 Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 00:57:06 +0530 Subject: [PATCH 3/6] fix: add saved search id fallback --- src/features/issues/lib/saved-searches.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts index b10100a..48e83b0 100644 --- a/src/features/issues/lib/saved-searches.ts +++ b/src/features/issues/lib/saved-searches.ts @@ -80,7 +80,11 @@ function saveSavedSearches(searches: SavedSearch[]): void { export function addSavedSearch( search: Omit, ): SavedSearch { - const savedSearch: SavedSearch = { + const id = + globalThis.crypto?.randomUUID?.() ?? + `${Date.now()}-${Math.random().toString(16).slice(2)}`; + + const savedSearch: SavedSearch = { ...search, id: crypto.randomUUID(), createdAt: new Date().toISOString(), From a290d1575e2364f93214aab8b5689e6544175c4b Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 01:02:20 +0530 Subject: [PATCH 4/6] fix: avoid insecure saved search id fallback --- src/features/issues/lib/saved-searches.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts index 48e83b0..38ceb4f 100644 --- a/src/features/issues/lib/saved-searches.ts +++ b/src/features/issues/lib/saved-searches.ts @@ -77,16 +77,23 @@ function saveSavedSearches(searches: SavedSearch[]): void { } } +let fallbackIdCounter = 0; + +function createSavedSearchId(): string { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + + fallbackIdCounter += 1; + return `${Date.now()}-${fallbackIdCounter}`; +} + export function addSavedSearch( search: Omit, ): SavedSearch { - const id = - globalThis.crypto?.randomUUID?.() ?? - `${Date.now()}-${Math.random().toString(16).slice(2)}`; - - const savedSearch: SavedSearch = { + const savedSearch: SavedSearch = { ...search, - id: crypto.randomUUID(), + id: createSavedSearchId(), createdAt: new Date().toISOString(), }; From b6e3f8e3e16bf521798263d118c419c4e297298b Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 13:54:49 +0530 Subject: [PATCH 5/6] fix: correct SonarCloud workflow arguments --- .github/workflows/sonarcloud.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 6bb6464..3c43876 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -57,7 +57,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} # Generate a token on Sonarcloud.io, add it to the secrets of this repo with the name SONAR_TOKEN with: # Additional arguments for the SonarScanner CLI - args: + args: > # Unique keys of your project and organization. You can find them in SonarCloud > Information (bottom-left menu) # mandatory -Dsonar.projectKey=arnabnandy7_openissue.dev From f13a44b9a687480bd2ece28801034a9ce3a8da1e Mon Sep 17 00:00:00 2001 From: SHASHANK SHEKHAR Date: Wed, 19 Aug 2026 20:02:36 +0530 Subject: [PATCH 6/6] fix: address saved search review feedback --- .../issues/components/issue-finder.tsx | 33 +++++++++++-------- src/features/issues/lib/saved-searches.ts | 12 +++++-- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/features/issues/components/issue-finder.tsx b/src/features/issues/components/issue-finder.tsx index 57d3004..b6466ee 100644 --- a/src/features/issues/components/issue-finder.tsx +++ b/src/features/issues/components/issue-finder.tsx @@ -100,19 +100,26 @@ export function IssueFinder() { setError("Enter a technology before saving the search."); return; } - - const savedSearch = addSavedSearch({ - name, - tech: tech.trim(), - label, - sort, - linkedPr, - hacktoberfest, - }); - - setSavedSearches((current) => [...current, savedSearch]); - setSavedSearchName(""); - setError(null); + try { + const savedSearch = addSavedSearch({ + name, + tech: tech.trim(), + label, + sort, + linkedPr, + hacktoberfest, + }); + + setSavedSearches((current) => [...current, savedSearch]); + setSavedSearchName(""); + setError(null); + }catch (saveError) { + setError( + saveError instanceof Error + ? saveError.message + : "Unable to save search.", + ); + } } function handleDeleteSavedSearch(id: string) { diff --git a/src/features/issues/lib/saved-searches.ts b/src/features/issues/lib/saved-searches.ts index 38ceb4f..3b6dd9c 100644 --- a/src/features/issues/lib/saved-searches.ts +++ b/src/features/issues/lib/saved-searches.ts @@ -65,14 +65,16 @@ export function getSavedSearches(): SavedSearch[] { } } -function saveSavedSearches(searches: SavedSearch[]): void { +function saveSavedSearches(searches: SavedSearch[]): boolean { if (typeof window === "undefined") { - return; + return false; } try { window.localStorage.setItem(STORAGE_KEY, JSON.stringify(searches)); + return true; } catch { + return false; // Ignore storage failures so the search UI remains usable. } } @@ -98,7 +100,11 @@ export function addSavedSearch( }; const searches = getSavedSearches(); - saveSavedSearches([...searches, savedSearch]); + const saved = saveSavedSearches([...searches, savedSearch]); + + if (!saved) { + throw new Error("Unable to save search."); + } return savedSearch; }