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(),
});