feat(app): tabbed sticky details panel

This commit is contained in:
rektdeckard
2023-02-03 10:31:15 -07:00
parent 1b8d6c48fc
commit 5e7f85ffdc
12 changed files with 398 additions and 18 deletions

View File

@@ -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;
}

View File

@@ -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<SnippetType | "SVG" | false>(
false,
2000
);
const ref = useRef<SVGSVGElement>(null);
const [snippets, tabs] = useMemo<
[Partial<Record<SnippetType, string>>, 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: (
<TagCloud
name={entry.name}
tags={Array.from(
new Set<string>([
...entry.categories,
...entry.name.split("-"),
...entry.tags,
])
)}
isDark={isDark}
/>
),
},
].concat(
RENDERED_SNIPPETS.map((type) => {
const isWeightSupported = supportsWeight({ type, weight });
return {
header: type,
content: (
<div className="snippet" key={type}>
<pre
tabIndex={0}
style={isWeightSupported ? undefined : snippetButtonStyle}
>
<span>
{isWeightSupported
? snippets[type]
: "This weight is not yet supported"}
</span>
<button
title="Copy snippet"
onClick={(e) => handleCopySnippet(e, type)}
disabled={!isWeightSupported}
style={isWeightSupported ? undefined : snippetButtonStyle}
>
{copied === type ? (
<CheckCircle size={24} color={successColor} weight="fill" />
) : (
<Copy
size={24}
color={
isWeightSupported
? buttonColor
: snippetButtonStyle.color
}
weight="fill"
/>
)}
</button>
</pre>
</div>
),
};
})
);
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<HTMLButtonElement, 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<HTMLButtonElement, MouseEvent>
) => {
event.currentTarget.blur();
if (!entry) return;
setCopied("SVG");
ref.current && void navigator.clipboard?.writeText(ref.current.outerHTML);
};
const handleDownloadSVG = (
event: React.MouseEvent<HTMLButtonElement, 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<HTMLButtonElement, 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 (
<AnimatePresence initial={false}>
{!!entry && (
<motion.aside
initial="initial"
animate="animate"
exit="exit"
variants={variants}
className={`detail-footer card ${isDark ? "dark" : "light"}`}
transition={{ duration: 0.1 }}
>
<div className="detail-preview">
<figure>
<entry.Icon ref={ref} size={64}></entry.Icon>
<figcaption>{entry.name}</figcaption>
</figure>
<small className="versioning">
in &ge; {entry.published_in.toFixed(1)}.0
</small>
</div>
<Tabs tabs={tabs} />
<div className="detail-actions">
<button style={buttonBarStyle} onClick={handleDownloadPNG}>
<Download size={24} color="currentColor" weight="fill" /> Download
PNG
</button>
<button style={buttonBarStyle} onClick={handleDownloadSVG}>
<Download size={24} color="currentColor" weight="fill" /> Download
SVG
</button>
<button style={buttonBarStyle} onClick={handleCopySVG}>
{copied === "SVG" ? (
<CheckCircle size={24} color={successColor} weight="fill" />
) : (
<Copy size={24} color="currentColor" weight="fill" />
)}
{copied === "SVG" ? "Copied!" : "Copy SVG"}
</button>
</div>
</motion.aside>
)}
</AnimatePresence>
);
};
export default DetailFooter;

View File

@@ -153,7 +153,8 @@ const DetailsPanel = (props: InfoPanelProps) => {
className="icon-preview"
>
<Icon ref={ref} color={color} weight={weight} size={192} />
<p>{name}</p>
<p className="name">{name}</p>
<p className="versioning">in &ge; {entry.published_in.toFixed(1)}.0</p>
<TagCloud
name={name}
tags={Array.from(

View File

@@ -1,6 +1,7 @@
.grid-container {
position: relative;
padding: 32px 16px;
min-height: 80vh;
/* min-height: 80vh; */
content-visibility: auto;
}
@@ -103,6 +104,15 @@
line-height: 16px;
}
.icon-preview > 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;
}

View File

@@ -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) => {
/>
))}
</motion.div>
<DetailFooter />
</div>
</IconContext.Provider>
);

View File

@@ -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<number>(0);
const offset = useRef({ top: 0, left: 0 });
const ref = useRef<any>();
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 && <span className="badge updated"></span>}
</p>
</motion.div>
<AnimatePresence initial={false}>
{/* <AnimatePresence initial={false}>
{isOpen && <DetailsPanel {...props} />}
</AnimatePresence>
</AnimatePresence> */}
</>
);
};

View File

@@ -2,7 +2,7 @@
display: flex;
flex-wrap: wrap;
justify-content: center;
padding: 24px;
/* padding: 24px; */
}
button.tag-button {

View File

@@ -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;
}

View File

@@ -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<number>(0);
return (
<div className="tabs">
<div className="tabs-header">
{tabs.map((tab, i) => (
<button
className={`tab ${activeIndex === i ? "active" : ""}`}
onClick={() => setActiveIndex(i)}
>
{tab.header}
</button>
))}
</div>
<div className="tab-content">{tabs[activeIndex]?.content}</div>
</div>
);
};
export default Tabs;

View File

@@ -0,0 +1,2 @@
export { default } from "./Tabs";
export type { Tab } from "./Tabs";

View File

@@ -1,27 +1,33 @@
import { atom } from "recoil";
import { IconStyle } from "@phosphor-icons/core";
import { IconEntry } from "@/lib";
export const searchQueryAtom = atom<string>({
key: "searchQueryAtom",
key: "searchQuery",
default: "",
});
export const iconWeightAtom = atom<IconStyle>({
key: "iconWeightAtom",
key: "iconWeight",
default: IconStyle.REGULAR,
});
export const iconSizeAtom = atom<number>({
key: "iconSizeAtom",
key: "iconSize",
default: 32,
});
export const iconColorAtom = atom<string>({
key: "iconColorAtom",
key: "iconColor",
default: "#000000",
});
export const iconPreviewOpenAtom = atom<string | false>({
key: "iconPreviewOpenAtom",
key: "iconPreviewOpen",
default: false,
});
export const selectionEntryAtom = atom<IconEntry | null>({
key: "selectionEntry",
default: null,
});

View File

@@ -20,7 +20,7 @@ const fuse = new Fuse(icons, {
});
export const filteredQueryResultsSelector = selector<ReadonlyArray<IconEntry>>({
key: "filteredQueryResultsSelector",
key: "filteredQueryResults",
get: ({ get }) => {
const query = get(searchQueryAtom).trim().toLowerCase();
if (!query) return icons;
@@ -36,7 +36,7 @@ type CategorizedIcons = Partial<Record<IconCategory, IconEntry[]>>;
export const categorizedQueryResultsSelector = selector<
Readonly<CategorizedIcons>
>({
key: "categorizedQueryResultsSelector",
key: "categorizedQueryResults",
get: ({ get }) => {
const filteredResults = get(filteredQueryResultsSelector);
return new Promise((resolve) =>
@@ -57,7 +57,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily<
ReadonlyArray<IconEntry>,
IconCategory
>({
key: "singleCategoryQueryResultsSelector",
key: "singleCategoryQueryResults",
get:
(category: IconCategory) =>
({ get }) => {
@@ -71,7 +71,7 @@ export const singleCategoryQueryResultsSelector = selectorFamily<
});
export const isDarkThemeSelector = selector<boolean>({
key: "isDarkThemeSelector",
key: "isDarkTheme",
get: ({ get }) => TinyColor(get(iconColorAtom)).isLight(),
});