diff --git a/src/components/App/App.css b/src/components/App/App.css index 52f74e3..9ede2c8 100644 --- a/src/components/App/App.css +++ b/src/components/App/App.css @@ -95,6 +95,17 @@ button.main-button svg { /* gap: 24px; */ } +figure { + margin: 0; + display: grid; + place-items: center; +} + +figcaption { + font-size: 14px; + text-align: center; +} + a.main-link { text-decoration: none; position: relative; @@ -128,3 +139,18 @@ a.main-link:hover:after { font-size: 24px; line-height: 0.5em; } + +.card { + border-radius: 8px; + border: 2px solid rgba(163, 159, 171, 0.1); +} + +.card.dark { + color: white; + background-color: #413c48; +} + +.card.light { + color: rgb(53, 49, 61); + background-color: #f6f5f6; +} diff --git a/src/components/IconGrid/DetailFooter.tsx b/src/components/IconGrid/DetailFooter.tsx new file mode 100644 index 0000000..06c4334 --- /dev/null +++ b/src/components/IconGrid/DetailFooter.tsx @@ -0,0 +1,247 @@ +import React, { useRef, useEffect, CSSProperties, useMemo } from "react"; +import { useRecoilValue, useRecoilState } from "recoil"; +import { useHotkeys } from "react-hotkeys-hook"; +import { motion, AnimatePresence, Variants } from "framer-motion"; +import { Svg2Png } from "svg2png-converter"; +import { saveAs } from "file-saver"; +import { Copy, X, CheckCircle, Download } from "phosphor-react"; +import ReactGA from "react-ga"; + +import { + iconWeightAtom, + iconSizeAtom, + iconColorAtom, + selectionEntryAtom, +} from "@/state/atoms"; +import { isDarkThemeSelector } from "@/state/selectors"; +import Tabs, { Tab } from "@/components/Tabs"; +import useTransientState from "@/hooks/useTransientState"; +import { IconEntry, SnippetType } from "@/lib"; +import { getCodeSnippets, supportsWeight } from "@/utils"; + +import TagCloud from "./TagCloud"; + +const variants: Variants = { + initial: { opacity: 0 }, + animate: { opacity: 1 }, + exit: { opacity: 0 }, +}; + +const RENDERED_SNIPPETS = [ + SnippetType.REACT, + SnippetType.VUE, + SnippetType.HTML, + SnippetType.FLUTTER, +]; + +const buttonColor = "#35313D"; +const successColor = "#1FA647"; +const disabledColor = "#B7B7B7"; + +const DetailFooter = () => { + const [entry, setSelectionEntry] = useRecoilState(selectionEntryAtom); + + const weight = useRecoilValue(iconWeightAtom); + const size = useRecoilValue(iconSizeAtom); + const color = useRecoilValue(iconColorAtom); + const isDark = useRecoilValue(isDarkThemeSelector); + const [copied, setCopied] = useTransientState( + false, + 2000 + ); + const ref = useRef(null); + + const [snippets, tabs] = useMemo< + [Partial>, Tab[]] + >(() => { + if (!entry) return [{}, []]; + + const snippets = getCodeSnippets({ + displayName: entry?.pascal_name!, + name: entry.name, + weight, + size, + color, + }); + + const snippetButtonStyle: CSSProperties = + weight === "duotone" + ? { color: disabledColor, userSelect: "none" } + : { color: buttonColor }; + + const tabs = [ + { + header: "Tags", + content: ( + ([ + ...entry.categories, + ...entry.name.split("-"), + ...entry.tags, + ]) + )} + isDark={isDark} + /> + ), + }, + ].concat( + RENDERED_SNIPPETS.map((type) => { + const isWeightSupported = supportsWeight({ type, weight }); + + return { + header: type, + content: ( +
+
+                
+                  {isWeightSupported
+                    ? snippets[type]
+                    : "This weight is not yet supported"}
+                
+                
+              
+
+ ), + }; + }) + ); + + return [snippets, tabs]; + }, [entry, weight, copied, isDark]); + + useHotkeys("esc", () => setSelectionEntry(null)); + + useEffect(() => { + if (!entry) return; + ReactGA.event({ + category: "Grid", + action: "Details", + label: entry.name, + }); + }, [entry]); + + const buttonBarStyle: CSSProperties = { + color: isDark ? "white" : buttonColor, + backgroundColor: "transparent", + }; + + const handleCopySnippet = ( + event: React.MouseEvent, + type: SnippetType + ) => { + event.currentTarget.blur(); + if (!entry) return; + + setCopied(type); + const data = snippets[type]; + data && void navigator.clipboard?.writeText(data); + }; + + const handleCopySVG = ( + event: React.MouseEvent + ) => { + event.currentTarget.blur(); + if (!entry) return; + + setCopied("SVG"); + ref.current && void navigator.clipboard?.writeText(ref.current.outerHTML); + }; + + const handleDownloadSVG = ( + event: React.MouseEvent + ) => { + event.currentTarget.blur(); + if (!entry) return; + if (!ref.current?.outerHTML) return; + + const blob = new Blob([ref.current.outerHTML]); + saveAs( + blob, + `${entry?.name}${weight === "regular" ? "" : `-${weight}`}.svg` + ); + }; + + const handleDownloadPNG = async ( + event: React.MouseEvent + ) => { + event.currentTarget.blur(); + if (!entry) return; + if (!ref.current?.outerHTML) return; + + Svg2Png.save( + ref.current, + `${entry?.name}${weight === "regular" ? "" : `-${weight}`}.png`, + { scaleX: 2.667, scaleY: 2.667 } + ); + }; + + return ( + + {!!entry && ( + +
+
+ +
{entry.name}
+
+ + in ≥ {entry.published_in.toFixed(1)}.0 + +
+ +
+ + + +
+
+ )} +
+ ); +}; + +export default DetailFooter; diff --git a/src/components/IconGrid/DetailsPanel.tsx b/src/components/IconGrid/DetailsPanel.tsx index 642e7de..1be8e03 100644 --- a/src/components/IconGrid/DetailsPanel.tsx +++ b/src/components/IconGrid/DetailsPanel.tsx @@ -153,7 +153,8 @@ const DetailsPanel = (props: InfoPanelProps) => { className="icon-preview" > -

{name}

+

{name}

+

in ≥ {entry.published_in.toFixed(1)}.0

p.name { + font-size: 16px; +} + +.versioning { + margin-top: 2px; + opacity: 0.6; +} + .icon-usage { flex: 1; padding: 56px 10% 56px 0; @@ -201,3 +211,23 @@ position: relative; top: -96px; } + +aside.detail-footer { + position: sticky; + bottom: 16px; + margin: auto; + max-width: 1120px; + display: grid; + grid-template-columns: 144px 1fr 160px; +} + +.detail-preview { + display: flex; + flex-direction: column; + align-items: center; + padding: 16px; +} + +.detail-actions { + padding: 16px; +} diff --git a/src/components/IconGrid/IconGrid.tsx b/src/components/IconGrid/IconGrid.tsx index 9b9f420..b2f06af 100644 --- a/src/components/IconGrid/IconGrid.tsx +++ b/src/components/IconGrid/IconGrid.tsx @@ -11,6 +11,7 @@ import { import useGridSpans from "@/hooks/useGridSpans"; import Notice from "@/components/Notice"; +import DetailFooter from "./DetailFooter"; import IconGridItem from "./IconGridItem"; import TagCloud from "./TagCloud"; import "./IconGrid.css"; @@ -75,6 +76,7 @@ const IconGrid = (_: IconGridProps) => { /> ))} + ); diff --git a/src/components/IconGrid/IconGridItem.tsx b/src/components/IconGrid/IconGridItem.tsx index c82a367..df22720 100644 --- a/src/components/IconGrid/IconGridItem.tsx +++ b/src/components/IconGrid/IconGridItem.tsx @@ -3,7 +3,7 @@ import { useRecoilState } from "recoil"; import { motion, AnimatePresence } from "framer-motion"; import { IconEntry } from "@/lib"; -import { iconPreviewOpenAtom } from "@/state/atoms"; +import { iconPreviewOpenAtom, selectionEntryAtom } from "@/state/atoms"; import DetailsPanel from "./DetailsPanel"; @@ -30,15 +30,15 @@ const itemVariants = { const IconGridItem = (props: IconGridItemProps) => { const { index, originOffset, entry } = props; const { name, Icon } = entry; - const [open, setOpen] = useRecoilState(iconPreviewOpenAtom); - const isOpen = open === name; + const [selection, setSelectionEntry] = useRecoilState(selectionEntryAtom); + const isOpen = selection?.name === name; const isNew = entry.tags.includes("*new*"); const isUpdated = entry.tags.includes("*updated*"); const delayRef = useRef(0); const offset = useRef({ top: 0, left: 0 }); const ref = useRef(); - const handleOpen = () => setOpen(isOpen ? false : name); + const handleOpen = () => setSelectionEntry(isOpen ? null : entry); // The measurement for all elements happens in the layoutEffect cycle // This ensures that when we calculate distance in the effect cycle @@ -88,9 +88,9 @@ const IconGridItem = (props: IconGridItemProps) => { {isUpdated && }

- + {/* {isOpen && } - + */} ); }; diff --git a/src/components/IconGrid/TagCloud.css b/src/components/IconGrid/TagCloud.css index 793edd6..e597508 100644 --- a/src/components/IconGrid/TagCloud.css +++ b/src/components/IconGrid/TagCloud.css @@ -2,7 +2,7 @@ display: flex; flex-wrap: wrap; justify-content: center; - padding: 24px; + /* padding: 24px; */ } button.tag-button { diff --git a/src/components/Tabs/Tabs.css b/src/components/Tabs/Tabs.css new file mode 100644 index 0000000..36428ae --- /dev/null +++ b/src/components/Tabs/Tabs.css @@ -0,0 +1,32 @@ +.tabs { + display: flex; + flex-direction: column; + border-left: 2px solid rgba(163, 159, 171, 0.1); + border-right: 2px solid rgba(163, 159, 171, 0.1); +} + +.tabs-header { + display: flex; + align-items: center; + gap: 8px; + border-bottom: 2px solid rgba(163, 159, 171, 0.1); +} + +button.tab { + all: unset; + padding: 4px; + font-size: 12px; + text-align: center; + cursor: pointer; + flex: 1; +} + +.tab.active { + background-color: rgba(194, 186, 196, 0.25); +} + +.tab-content { + flex: 1; + display: grid; + place-items: center; +} diff --git a/src/components/Tabs/Tabs.tsx b/src/components/Tabs/Tabs.tsx new file mode 100644 index 0000000..06d1ecd --- /dev/null +++ b/src/components/Tabs/Tabs.tsx @@ -0,0 +1,34 @@ +import { ReactNode, useState } from "react"; + +import "./Tabs.css"; + +export type Tab = { + header: ReactNode; + content: ReactNode; +}; + +type TabsProps = { + tabs: Tab[]; +}; + +const Tabs = ({ tabs }: TabsProps) => { + const [activeIndex, setActiveIndex] = useState(0); + + return ( +
+
+ {tabs.map((tab, i) => ( + + ))} +
+
{tabs[activeIndex]?.content}
+
+ ); +}; + +export default Tabs; diff --git a/src/components/Tabs/index.ts b/src/components/Tabs/index.ts new file mode 100644 index 0000000..18ff5c3 --- /dev/null +++ b/src/components/Tabs/index.ts @@ -0,0 +1,2 @@ +export { default } from "./Tabs"; +export type { Tab } from "./Tabs"; diff --git a/src/state/atoms.ts b/src/state/atoms.ts index 2afb5c8..b6d570f 100644 --- a/src/state/atoms.ts +++ b/src/state/atoms.ts @@ -1,27 +1,33 @@ import { atom } from "recoil"; import { IconStyle } from "@phosphor-icons/core"; +import { IconEntry } from "@/lib"; export const searchQueryAtom = atom({ - key: "searchQueryAtom", + key: "searchQuery", default: "", }); export const iconWeightAtom = atom({ - key: "iconWeightAtom", + key: "iconWeight", default: IconStyle.REGULAR, }); export const iconSizeAtom = atom({ - key: "iconSizeAtom", + key: "iconSize", default: 32, }); export const iconColorAtom = atom({ - key: "iconColorAtom", + key: "iconColor", default: "#000000", }); export const iconPreviewOpenAtom = atom({ - key: "iconPreviewOpenAtom", + key: "iconPreviewOpen", default: false, }); + +export const selectionEntryAtom = atom({ + key: "selectionEntry", + default: null, +}); diff --git a/src/state/selectors.ts b/src/state/selectors.ts index 7f43e8b..d76514a 100644 --- a/src/state/selectors.ts +++ b/src/state/selectors.ts @@ -20,7 +20,7 @@ const fuse = new Fuse(icons, { }); export const filteredQueryResultsSelector = selector>({ - key: "filteredQueryResultsSelector", + key: "filteredQueryResults", get: ({ get }) => { const query = get(searchQueryAtom).trim().toLowerCase(); if (!query) return icons; @@ -36,7 +36,7 @@ type CategorizedIcons = Partial>; export const categorizedQueryResultsSelector = selector< Readonly >({ - key: "categorizedQueryResultsSelector", + key: "categorizedQueryResults", get: ({ get }) => { const filteredResults = get(filteredQueryResultsSelector); return new Promise((resolve) => @@ -57,7 +57,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily< ReadonlyArray, IconCategory >({ - key: "singleCategoryQueryResultsSelector", + key: "singleCategoryQueryResults", get: (category: IconCategory) => ({ get }) => { @@ -71,7 +71,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily< }); export const isDarkThemeSelector = selector({ - key: "isDarkThemeSelector", + key: "isDarkTheme", get: ({ get }) => TinyColor(get(iconColorAtom)).isLight(), });