diff --git a/cspell-dict.txt b/cspell-dict.txt index 8218957c..5211f2a5 100644 --- a/cspell-dict.txt +++ b/cspell-dict.txt @@ -1,3 +1,5 @@ quicksnip slugified slugifyed +sublanguage +fastapi \ No newline at end of file diff --git a/snippets/javascript/[react]/basics/hello-world.md b/snippets/javascript/[react]/basics/hello-world.md new file mode 100644 index 00000000..5cdaba09 --- /dev/null +++ b/snippets/javascript/[react]/basics/hello-world.md @@ -0,0 +1,21 @@ +--- +title: Hello, World! +description: Show Hello World on the page. +author: ACR1209 +tags: printing,hello-world +--- + +```tsx +import React from 'react'; +import ReactDOM from 'react-dom'; + +const App = () => { + return ( +
+

Hello, World!

+
+ ); +}; + +ReactDOM.render(, document.getElementById('root')); +``` diff --git a/snippets/javascript/[react]/icon.svg b/snippets/javascript/[react]/icon.svg new file mode 100644 index 00000000..b9025712 --- /dev/null +++ b/snippets/javascript/[react]/icon.svg @@ -0,0 +1,9 @@ + + React Logo + + + + + + + \ No newline at end of file diff --git a/snippets/python/[fastapi]/basics/hello-world.md b/snippets/python/[fastapi]/basics/hello-world.md new file mode 100644 index 00000000..69afb9cf --- /dev/null +++ b/snippets/python/[fastapi]/basics/hello-world.md @@ -0,0 +1,21 @@ +--- +title: Hello, World! +description: Returns Hello, World! when it recives a GET request made to the root endpoint. +author: ACR1209 +tags: printing,hello-world,web,api +--- + +```py +from typing import Union +from fastapi import FastAPI + +app = FastAPI() + + +@app.get("/") +def read_root(): + return {"msg": "Hello, World!"} + +# Usage: +# -> Go to http://127.0.0.1:8000/ and you'll see {"msg", "Hello, World!"} +``` diff --git a/snippets/python/[fastapi]/icon.svg b/snippets/python/[fastapi]/icon.svg new file mode 100644 index 00000000..a7be660d --- /dev/null +++ b/snippets/python/[fastapi]/icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/LanguageSelector.tsx b/src/components/LanguageSelector.tsx index d8e208fe..8a62d651 100644 --- a/src/components/LanguageSelector.tsx +++ b/src/components/LanguageSelector.tsx @@ -1,28 +1,50 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useMemo } from "react"; import { useAppContext } from "@contexts/AppContext"; import { useKeyboardNavigation } from "@hooks/useKeyboardNavigation"; import { useLanguages } from "@hooks/useLanguages"; import { LanguageType } from "@types"; +import SubLanguageSelector from "./SubLanguageSelector"; + // Inspired by https://blog.logrocket.com/creating-custom-select-dropdown-css/ const LanguageSelector = () => { const { language, setLanguage } = useAppContext(); const { fetchedLanguages, loading, error } = useLanguages(); + const allLanguages = useMemo( + () => + fetchedLanguages.flatMap((lang) => + lang.subLanguages.length > 0 + ? [ + lang, + ...lang.subLanguages.map((subLang) => ({ + ...subLang, + mainLanguage: lang, + subLanguages: [], + })), + ] + : [lang] + ), + [fetchedLanguages] + ); const dropdownRef = useRef(null); const [isOpen, setIsOpen] = useState(false); + const [openedLanguages, setOpenedLanguages] = useState([]); const handleSelect = (selected: LanguageType) => { setLanguage(selected); setIsOpen(false); + setOpenedLanguages([]); }; const { focusedIndex, handleKeyDown, resetFocus, focusFirst } = useKeyboardNavigation({ - items: fetchedLanguages, + items: allLanguages, isOpen, + openedLanguages, + toggleDropdown: (openedLang) => handleToggleSublanguage(openedLang), onSelect: handleSelect, onClose: () => setIsOpen(false), }); @@ -38,6 +60,20 @@ const LanguageSelector = () => { }, 0); }; + const handleToggleSublanguage = (openedLang: LanguageType) => { + const isAlreadyOpened = openedLanguages.some( + (lang) => lang.name === openedLang.name + ); + + if (!isAlreadyOpened) { + setOpenedLanguages((prev) => [...prev, openedLang]); + } else { + setOpenedLanguages((prev) => + prev.filter((lang) => lang.name !== openedLang.name) + ); + } + }; + const toggleDropdown = () => { setIsOpen((prev) => { if (!prev) setTimeout(focusFirst, 0); @@ -52,6 +88,13 @@ const LanguageSelector = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isOpen]); + useEffect(() => { + if (language.mainLanguage) { + handleToggleSublanguage(language.mainLanguage); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [language]); + useEffect(() => { if (isOpen && focusedIndex >= 0) { const element = document.querySelector( @@ -90,23 +133,35 @@ const LanguageSelector = () => { onKeyDown={handleKeyDown} tabIndex={-1} > - {fetchedLanguages.map((lang, index) => ( -
  • handleSelect(lang)} - className={`selector__item ${ - language.name === lang.name ? "selected" : "" - } ${focusedIndex === index ? "focused" : ""}`} - aria-selected={language.name === lang.name} - > - -
  • - ))} + {fetchedLanguages.map((lang, index) => + lang.subLanguages.length > 0 ? ( + { + setIsOpen(false); + }} + opened={openedLanguages.includes(lang)} + onDropdownToggle={handleToggleSublanguage} + /> + ) : ( +
  • handleSelect(lang)} + className={`selector__item ${ + language.name === lang.name ? "selected" : "" + } ${focusedIndex === index ? "focused" : ""}`} + aria-selected={language.name === lang.name} + > + +
  • + ) + )} )} diff --git a/src/components/SnippetList.tsx b/src/components/SnippetList.tsx index 8868b9f1..0b9a2025 100644 --- a/src/components/SnippetList.tsx +++ b/src/components/SnippetList.tsx @@ -87,7 +87,7 @@ const SnippetList = () => { )} diff --git a/src/components/SubLanguageSelector.tsx b/src/components/SubLanguageSelector.tsx new file mode 100644 index 00000000..0b88210e --- /dev/null +++ b/src/components/SubLanguageSelector.tsx @@ -0,0 +1,86 @@ +import { useAppContext } from "@contexts/AppContext"; +import { LanguageType } from "@types"; + +type SubLanguageSelectorProps = { + mainLanguage: LanguageType; + afterSelect: () => void; + onDropdownToggle: (openedLang: LanguageType) => void; + opened: boolean; +}; + +const SubLanguageSelector = ({ + mainLanguage, + afterSelect, + onDropdownToggle, + opened, +}: SubLanguageSelectorProps) => { + const { language, setLanguage } = useAppContext(); + + const handleSelect = (selected: LanguageType) => { + setLanguage(selected); + onDropdownToggle(mainLanguage); + afterSelect(); + }; + + return ( + <> +
  • setLanguage(mainLanguage)} + > + +
  • + + {opened && ( + <> + {mainLanguage.subLanguages.map((subLanguage) => ( +
  • { + handleSelect({ + ...subLanguage, + mainLanguage: mainLanguage, + subLanguages: [], + }); + }} + > + +
  • + ))} + + )} + + ); +}; + +export default SubLanguageSelector; diff --git a/src/contexts/AppContext.tsx b/src/contexts/AppContext.tsx index 30dd366e..ac7b51ce 100644 --- a/src/contexts/AppContext.tsx +++ b/src/contexts/AppContext.tsx @@ -6,7 +6,7 @@ import { AppState, LanguageType, SnippetType } from "@types"; const defaultLanguage: LanguageType = { name: "JAVASCRIPT", icon: "/icons/javascript.svg", - subIndexes: [], + subLanguages: [], }; // TODO: add custom loading and error handling diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index 4bd00685..d1d4a631 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -9,7 +9,7 @@ import { useFetch } from "./useFetch"; export const useCategories = () => { const { language } = useAppContext(); const { data, loading, error } = useFetch( - `/consolidated/${slugify(language.name)}.json` + `/consolidated/${language.mainLanguage ? `${slugify(language.mainLanguage.name)}--${slugify(language.name)}` : slugify(language.name)}.json` ); const fetchedCategories = useMemo(() => { diff --git a/src/hooks/useKeyboardNavigation.ts b/src/hooks/useKeyboardNavigation.ts index 46bda82c..401ead2b 100644 --- a/src/hooks/useKeyboardNavigation.ts +++ b/src/hooks/useKeyboardNavigation.ts @@ -7,11 +7,14 @@ interface UseKeyboardNavigationProps { isOpen: boolean; onSelect: (item: LanguageType) => void; onClose: () => void; + toggleDropdown: (openedLang: LanguageType) => void; + openedLanguages: LanguageType[]; } const keyboardEventKeys = { arrowDown: "ArrowDown", arrowUp: "ArrowUp", + arrowRight: "ArrowRight", enter: "Enter", escape: "Escape", } as const; @@ -22,8 +25,10 @@ type KeyboardEventKeys = export const useKeyboardNavigation = ({ items, isOpen, + openedLanguages, onSelect, onClose, + toggleDropdown, }: UseKeyboardNavigationProps) => { const [focusedIndex, setFocusedIndex] = useState(-1); @@ -42,9 +47,28 @@ export const useKeyboardNavigation = ({ case "ArrowUp": setFocusedIndex((prev) => (prev > 0 ? prev - 1 : items.length - 1)); break; + case "ArrowRight": + if (focusedIndex >= 0) { + const selectedItem = items.filter( + (item) => + !item.mainLanguage || + openedLanguages.includes(item.mainLanguage) + )[focusedIndex]; + + if (selectedItem.subLanguages.length > 0) { + toggleDropdown(selectedItem); + } + } + break; case "Enter": if (focusedIndex >= 0) { - onSelect(items[focusedIndex]); + onSelect( + items.filter( + (item) => + !item.mainLanguage || + openedLanguages.includes(item.mainLanguage) + )[focusedIndex] + ); } break; case "Escape": diff --git a/src/hooks/useSnippets.ts b/src/hooks/useSnippets.ts index a9d85499..f0d30800 100644 --- a/src/hooks/useSnippets.ts +++ b/src/hooks/useSnippets.ts @@ -7,7 +7,7 @@ import { useFetch } from "./useFetch"; export const useSnippets = () => { const { language, category } = useAppContext(); const { data, loading, error } = useFetch( - `/consolidated/${slugify(language.name)}.json` + `/consolidated/${language.mainLanguage ? `${slugify(language.mainLanguage.name)}--${slugify(language.name)}` : slugify(language.name)}.json` ); const fetchedSnippets = data diff --git a/src/styles/main.css b/src/styles/main.css index 9f8436bb..5ffc06ef 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -403,6 +403,37 @@ abbr { border-radius: var(--br-md); } +.sublanguage__item { + margin-left: 1.5rem; +} + +.sublanguage__button{ + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem 0.5rem; + border: 0; + background-color: transparent; +} + +.sublanguage__arrow { + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-top: 7px solid var(--clr-text-primary); /* [1] */ + transform: rotate(-90deg); + transition: transform 100ms ease; + cursor: pointer; +} + +[aria-expanded="true"] .sublanguage__arrow { + transform: rotate(0deg); +} + +.selector__item.selected .sublanguage__arrow { + border-top-color: var(--clr-text-tertiary); +} + .selector__item label { width: 100%; padding: 0.25em 0.75em; diff --git a/src/types/index.ts b/src/types/index.ts index da4ade07..8e489f44 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,7 +1,8 @@ export type LanguageType = { name: string; icon: string; - subIndexes: { + mainLanguage?: LanguageType; + subLanguages: { name: string; icon: string; }[]; @@ -19,6 +20,7 @@ export type SnippetType = { code: string; tags: string[]; contributors: string[]; + extension: string; }; export type RawSnippetType = { @@ -28,6 +30,7 @@ export type RawSnippetType = { code: string; tags: string; contributors?: string; + extension: string; }; export type AppState = { diff --git a/utils/consolidateSnippets.ts b/utils/consolidateSnippets.ts index d8d47911..280278ee 100644 --- a/utils/consolidateSnippets.ts +++ b/utils/consolidateSnippets.ts @@ -23,7 +23,7 @@ const index: LanguageType[] = []; for (const language of languages) { copyFileSync(language.icon, join(iconPath, `${slugify(language.name)}.svg`)); - const subIndexes: LanguageType["subIndexes"] = []; + const subLanguages: LanguageType["subLanguages"] = []; for (const subLanguage of language.subLanguages) { const joinedName = `${slugify(language.name)}--${slugify(subLanguage.name)}`; @@ -31,7 +31,7 @@ for (const language of languages) { const subLanguageFilePath = join(dataPath, `${joinedName}.json`); copyFileSync(subLanguage.icon, join(iconPath, iconName)); - subIndexes.push({ + subLanguages.push({ name: subLanguage.name.toUpperCase(), icon: `/icons/${iconName}`, }); @@ -45,7 +45,7 @@ for (const language of languages) { index.push({ name: language.name.toUpperCase(), icon: `/icons/${slugify(language.name)}.svg`, - subIndexes, + subLanguages, }); const languageFilePath = join(dataPath, `${slugify(language.name)}.json`); diff --git a/utils/snippetParser.ts b/utils/snippetParser.ts index c64f2988..4b4a4d7d 100644 --- a/utils/snippetParser.ts +++ b/utils/snippetParser.ts @@ -73,6 +73,7 @@ function parseSnippet( } cursor += match[0].length; + const extension = match[0].replace(/[\r\n`-]/g, ""); match = codeRegex.exec(fromCursor()); if (match === null) { @@ -94,6 +95,7 @@ function parseSnippet( .map((contributor) => contributor.trim()) .filter((contributor) => contributor), code: code.replace(/\r\n/g, "\n"), + extension, }; }