-
+
+
{
+ const newValue = e.target.value;
+ if (!newValue) {
+ clearSearch();
+ return;
+ }
+ setSearchText(newValue);
+ }}
/>
+ {!searchText && (
+
+ )}
);
};
diff --git a/src/components/SnippetList.tsx b/src/components/SnippetList.tsx
index 8868b9f1..fa4b2518 100644
--- a/src/components/SnippetList.tsx
+++ b/src/components/SnippetList.tsx
@@ -1,37 +1,65 @@
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
-import { useState } from "react";
+import { useEffect, useState } from "react";
+import { useSearchParams } from "react-router-dom";
import { useAppContext } from "@contexts/AppContext";
import { useSnippets } from "@hooks/useSnippets";
import { SnippetType } from "@types";
+import { QueryParams } from "@utils/enums";
+import { slugify } from "@utils/slugify";
import { LeftAngleArrowIcon } from "./Icons";
import SnippetModal from "./SnippetModal";
const SnippetList = () => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const shouldReduceMotion = useReducedMotion();
+
const { language, snippet, setSnippet } = useAppContext();
const { fetchedSnippets } = useSnippets();
- const [isModalOpen, setIsModalOpen] = useState(false);
- const shouldReduceMotion = useReducedMotion();
-
- if (!fetchedSnippets)
- return (
-
-
-
- );
+ const [isModalOpen, setIsModalOpen] = useState
(false);
- const handleOpenModal = (activeSnippet: SnippetType) => {
+ const handleOpenModal = (selected: SnippetType) => () => {
setIsModalOpen(true);
- setSnippet(activeSnippet);
+ setSnippet(selected);
+ searchParams.set(QueryParams.SNIPPET, slugify(selected.title));
+ setSearchParams(searchParams);
};
const handleCloseModal = () => {
setIsModalOpen(false);
setSnippet(null);
+ searchParams.delete(QueryParams.SNIPPET);
+ setSearchParams(searchParams);
};
+ /**
+ * open the relevant modal if the snippet is in the search params
+ */
+ useEffect(() => {
+ const snippetSlug = searchParams.get(QueryParams.SNIPPET);
+ if (!snippetSlug) {
+ return;
+ }
+
+ const selectedSnippet = (fetchedSnippets ?? []).find(
+ (item) => slugify(item.title) === snippetSlug
+ );
+ if (selectedSnippet) {
+ handleOpenModal(selectedSnippet)();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchedSnippets, searchParams]);
+
+ if (!fetchedSnippets) {
+ return (
+
+
+
+ );
+ }
+
return (
<>
@@ -67,7 +95,7 @@ const SnippetList = () => {
handleOpenModal(snippet)}
+ onClick={handleOpenModal(snippet)}
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.98 }}
>
diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx
index 30dd366e..48fee1b1 100644
--- a/src/contexts/AppContext.tsx
+++ b/src/contexts/AppContext.tsx
@@ -1,43 +1,58 @@
-import { createContext, FC, useContext, useState } from "react";
+import { createContext, FC, useContext, useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import { useLanguages } from "@hooks/useLanguages";
import { AppState, LanguageType, SnippetType } from "@types";
-
-// tokens
-const defaultLanguage: LanguageType = {
- name: "JAVASCRIPT",
- icon: "/icons/javascript.svg",
- subIndexes: [],
-};
-
-// TODO: add custom loading and error handling
-const defaultState: AppState = {
- language: defaultLanguage,
- setLanguage: () => {},
- category: "",
- setCategory: () => {},
- snippet: null,
- setSnippet: () => {},
-};
+import { configureUserSelection } from "@utils/configureUserSelection";
+import { defaultState } from "@utils/consts";
const AppContext = createContext(defaultState);
export const AppProvider: FC<{ children: React.ReactNode }> = ({
children,
}) => {
- const [language, setLanguage] = useState(defaultLanguage);
- const [category, setCategory] = useState("");
+ const { languageName, categoryName } = useParams();
+
+ const { fetchedLanguages } = useLanguages();
+
+ const [language, setLanguage] = useState(null);
+ const [category, setCategory] = useState(null);
const [snippet, setSnippet] = useState(null);
+ const [searchText, setSearchText] = useState("");
+
+ const configure = async () => {
+ const { language, category } = await configureUserSelection({
+ languageName,
+ categoryName,
+ });
+
+ setLanguage(language);
+ setCategory(category);
+ };
+
+ useEffect(() => {
+ configure();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchedLanguages]);
+
+ if (language === null || category === null) {
+ return Loading...
;
+ }
return (
{children}
diff --git a/src/hooks/useSnippets.ts b/src/hooks/useSnippets.ts
index a9d85499..62f515c1 100644
--- a/src/hooks/useSnippets.ts
+++ b/src/hooks/useSnippets.ts
@@ -1,18 +1,39 @@
+import { useMemo } from "react";
+import { useSearchParams } from "react-router-dom";
+
import { useAppContext } from "@contexts/AppContext";
import { CategoryType } from "@types";
+import { QueryParams } from "@utils/enums";
import { slugify } from "@utils/slugify";
import { useFetch } from "./useFetch";
export const useSnippets = () => {
+ const [searchParams] = useSearchParams();
+
const { language, category } = useAppContext();
const { data, loading, error } = useFetch(
`/consolidated/${slugify(language.name)}.json`
);
- const fetchedSnippets = data
- ? data.find((item) => item.name === category)?.snippets
- : [];
+ const fetchedSnippets = useMemo(() => {
+ if (!data) {
+ return [];
+ }
+
+ if (searchParams.has(QueryParams.SEARCH)) {
+ return data
+ .find((item) => item.name === category)
+ ?.snippets.filter((item) =>
+ item.title
+ .toLowerCase()
+ .includes(
+ (searchParams.get(QueryParams.SEARCH) || "").toLowerCase()
+ )
+ );
+ }
+ return data.find((item) => item.name === category)?.snippets;
+ }, [category, data, searchParams]);
return { fetchedSnippets, loading, error };
};
diff --git a/src/main.tsx b/src/main.tsx
index 1a01bb18..957d266f 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,15 +2,14 @@ import "@styles/main.css";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
+import { BrowserRouter } from "react-router-dom";
-import { AppProvider } from "@contexts/AppContext";
-
-import App from "./App";
+import AppRouter from "@AppRouter";
createRoot(document.getElementById("root")!).render(
-
-
-
+
+
+
);
diff --git a/src/styles/main.css b/src/styles/main.css
index 9f8436bb..ba267082 100644
--- a/src/styles/main.css
+++ b/src/styles/main.css
@@ -76,6 +76,7 @@
--fw-normal: 400;
/* Border radius */
+ --br-sm: 0.25rem;
--br-md: 0.5rem;
--br-lg: 0.75rem;
}
@@ -301,12 +302,38 @@ abbr {
border: 1px solid var(--clr-border-primary);
border-radius: var(--br-md);
padding: 0.75em 1.125em;
+ position: relative;
&:is(:hover, :focus-within) {
border-color: var(--clr-accent);
}
}
+.search-field label {
+ position: absolute;
+ margin-left: 2.25em;
+}
+
+.search-field:hover, .search-field:hover * {
+ cursor: pointer;
+}
+
+/* hide the label when the search field input element is focused */
+.search-field input:focus + label {
+ display: none;
+}
+
+.search-field label kbd {
+ background-color: var(--clr-bg-secondary);
+ border: 1px solid var(--clr-border-primary);
+ border-radius: var(--br-sm);
+ padding: 0.25em 0.5em;
+ margin: 0 0.25em;
+ font-family: var(--ff-mono);
+ font-weight: var(--fw-bold);
+ color: var(--clr-text-primary);
+}
+
.search-field > input {
background-color: transparent;
border: none;
diff --git a/src/types/index.ts b/src/types/index.ts
index 9f6c4fb2..444d328b 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -27,4 +27,6 @@ export type AppState = {
setCategory: React.Dispatch>;
snippet: SnippetType | null;
setSnippet: React.Dispatch>;
+ searchText: string;
+ setSearchText: React.Dispatch>;
};
diff --git a/src/utils/configureUserSelection.ts b/src/utils/configureUserSelection.ts
new file mode 100644
index 00000000..cc299588
--- /dev/null
+++ b/src/utils/configureUserSelection.ts
@@ -0,0 +1,48 @@
+import { CategoryType, LanguageType } from "@types";
+
+import { defaultCategory, defaultLanguage } from "./consts";
+import { slugify } from "./slugify";
+
+export async function configureUserSelection({
+ languageName,
+ categoryName,
+}: {
+ languageName: string | undefined;
+ categoryName?: string | undefined;
+}): Promise<{ language: LanguageType; category: CategoryType["name"] }> {
+ const slugifiedLanguageName = languageName
+ ? slugify(languageName)
+ : undefined;
+ const slugifiedCategoryName = categoryName
+ ? slugify(categoryName)
+ : undefined;
+
+ const fetchedLanguages: LanguageType[] = await fetch(
+ "/consolidated/_index.json"
+ ).then((res) => res.json());
+
+ const language =
+ fetchedLanguages.find(
+ (lang) => slugify(lang.name) === slugifiedLanguageName
+ ) ?? defaultLanguage;
+
+ let category: CategoryType | undefined;
+ try {
+ const fetchedCategories: CategoryType[] = await fetch(
+ `/consolidated/${slugify(language.name)}.json`
+ ).then((res) => res.json());
+ category = fetchedCategories.find(
+ (item) => slugify(item.name) === slugifiedCategoryName
+ );
+ if (category === undefined && fetchedCategories.length > 0) {
+ category = fetchedCategories[0];
+ }
+ if (category === undefined) {
+ category = defaultCategory;
+ }
+ } catch (_error) {
+ category = defaultCategory;
+ }
+
+ return { language, category: category.name };
+}
diff --git a/src/utils/consts.ts b/src/utils/consts.ts
new file mode 100644
index 00000000..09fdf0a4
--- /dev/null
+++ b/src/utils/consts.ts
@@ -0,0 +1,24 @@
+import { AppState, CategoryType, LanguageType } from "@types";
+
+export const defaultLanguage: LanguageType = {
+ name: "JAVASCRIPT",
+ icon: "/icons/javascript.svg",
+ subIndexes: [],
+};
+
+export const defaultCategory: CategoryType = {
+ name: "",
+ snippets: [],
+};
+
+// TODO: add custom loading and error handling
+export const defaultState: AppState = {
+ language: defaultLanguage,
+ setLanguage: () => {},
+ category: defaultCategory.name,
+ setCategory: () => {},
+ snippet: null,
+ setSnippet: () => {},
+ searchText: "",
+ setSearchText: () => {},
+};
diff --git a/src/utils/enums.ts b/src/utils/enums.ts
new file mode 100644
index 00000000..61de7575
--- /dev/null
+++ b/src/utils/enums.ts
@@ -0,0 +1,4 @@
+export enum QueryParams {
+ SEARCH = "q",
+ SNIPPET = "snippet",
+}