Add site categories (#876)

* bump package

* Add Logo

* remove console

* prettify it

* add favicons and fix issue

* Add categorie page

* add drag and drop

* Make drag and drop working

* Add drag options

* Add modal

* small styling fixes

* fix search

* Add code editor

* Add more styling

* Add more categories

* create context provider

* refactor eslint thing

* update chakra-ui

* improve, category bar

* Add sortly

* Add categories

* Try to fix new categories

* Fix Categories

* Add docs Menu Tree data

* Start with sectiontitles

* Create link list

* Add Docs menu to mobile

* Add some more pages and text

* Optimize text

* add license to the menu

* update packages

* Fix build

* Update title

* Remove ModifiedTooltip

* Fix assets

* add yarn to copy-assets command

* install deps

* update

* try something

* new categories page

* try something

* Add icons reorder

* add new icons page

* add category view button

* add categories

* Fix vercel build

* Add sidebar and costumize button

* fix merge conlfict

* Remove console.logs

* add sidebar

* Add icon overview

* Fix key render issue

* Fix types

* Fix category render

* Fix build

* update lockfile

* Added category icon and icon count in list. Moved scrollbar to the left to make it less intrusive

---------

Co-authored-by: Eric Fennis <eric.fennis@endurance.com>
Co-authored-by: Eric Fennis <eric@dreamteam.nl>
Co-authored-by: Karsa <karsa@karsa.org>
This commit is contained in:
Eric Fennis
2023-04-09 16:49:11 +02:00
committed by GitHub
parent d0826259d1
commit 2ee208652f
44 changed files with 1671 additions and 520 deletions

15
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "pwa-chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:8080",
"webRoot": "${workspaceFolder}"
}
]
}

7
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,7 @@
{
"cSpell.words": [
"devs",
"preact",
"Preact"
]
}

View File

@@ -1,7 +1,7 @@
import { forwardRef, createElement, ReactSVG, SVGProps } from 'react'; import { forwardRef, createElement, ReactSVG, SVGProps } from 'react';
import defaultAttributes from './defaultAttributes'; import defaultAttributes from './defaultAttributes';
type IconNode = [elementName: keyof ReactSVG, attrs: Record<string, string>][] export type IconNode = [elementName: keyof ReactSVG, attrs: Record<string, string>][]
export type SVGAttributes = Partial<SVGProps<SVGSVGElement>> export type SVGAttributes = Partial<SVGProps<SVGSVGElement>>

353
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ module.exports = {
'eslint:recommended', 'eslint:recommended',
'plugin:@typescript-eslint/recommended', 'plugin:@typescript-eslint/recommended',
'plugin:@next/next/recommended', 'plugin:@next/next/recommended',
'plugin:react-hooks/recommended'
], ],
parserOptions: { parserOptions: {
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,

View File

@@ -6,7 +6,7 @@
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"copy-assets": "mkdir -p ./public/docs/images && cp -rf ../docs/images ./public/docs", "copy-assets": "mkdir -p ./public/docs/images && cp -rf ../docs/images ./public/docs",
"prebuild": "ts-node --swc scripts/preBuild.ts", "prebuild": "ts-node --swc scripts/preBuild.ts && pnpm -w lucide-react build",
"build": "pnpm copy-assets && pnpm prebuild && next build", "build": "pnpm copy-assets && pnpm prebuild && next build",
"export": "next export -o build", "export": "next export -o build",
"deploy": "pnpm build && pnpm export", "deploy": "pnpm build && pnpm export",
@@ -24,16 +24,17 @@
"@next/mdx": "^11.0.0", "@next/mdx": "^11.0.0",
"@svgr/webpack": "^6.3.1", "@svgr/webpack": "^6.3.1",
"downloadjs": "^1.4.7", "downloadjs": "^1.4.7",
"framer-motion": "^6.2.8",
"element-to-path": "^1.2.1", "element-to-path": "^1.2.1",
"framer-motion": "^4",
"fuse.js": "^6.5.3", "fuse.js": "^6.5.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"lodash": "^4.17.20", "lodash": "^4.17.20",
"lucide-react": "^0.94.0", "lucide-react": "workspace:*",
"next": "12", "next": "12",
"next-mdx-remote": "^3.0.2", "next-mdx-remote": "^3.0.2",
"object-path": "0.11.5",
"prism-react-renderer": "^1.2.1", "prism-react-renderer": "^1.2.1",
"react": "17.0.2", "react": "17.0.2",
"react-color": "^2.19.3", "react-color": "^2.19.3",
@@ -59,6 +60,7 @@
"babel-loader": "^8.1.0", "babel-loader": "^8.1.0",
"eslint": "^8.22.0", "eslint": "^8.22.0",
"eslint-config-prettier": "^8.5.0", "eslint-config-prettier": "^8.5.0",
"eslint-plugin-react-hooks": "^4.6.0",
"jest": "^26.5.2", "jest": "^26.5.2",
"node-fetch": "2", "node-fetch": "2",
"prettier": "^2.3.2", "prettier": "^2.3.2",

View File

@@ -17,3 +17,7 @@ body {
min-height: 100%; min-height: 100%;
padding-bottom: 80px; padding-bottom: 80px;
} }
html:has(*:target) {
scroll-behavior: smooth;
}

View File

@@ -0,0 +1,123 @@
import {
Box,
Button,
Flex,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Text,
Divider,
} from '@chakra-ui/react';
import theme from '../lib/theme';
import { useMemo, useState } from 'react';
import CodeBlock from './CodeBlock';
import CopyButton from './CopyButton';
import { IconEntity } from 'src/types';
const CategoryChangesBar = ({ categories, changes }) => {
const [modalOpen, setModalOpen] = useState(false);
const [showCode, setShowCode] = useState(false);
const handleSubmit = () => {
setModalOpen(true);
};
const openPullRequestUrl = 'https://github.com/lucide-icons/lucide/edit/master/categories.json';
const onClose = () => {
setModalOpen(false);
};
const newMappedCategories = useMemo(() => {
return Object.fromEntries(
Object.entries(categories).map(([category, icons]) => [
category,
(icons as IconEntity[]).map(({ name }) => name),
]),
);
}, [categories]);
const categoryCode = useMemo(() => JSON.stringify(newMappedCategories, null, ' '), [
newMappedCategories,
]);
return (
<>
<Box
borderWidth="1px"
rounded="lg"
width="full"
boxShadow={theme.shadows.xl}
position="relative"
padding={4}
maxW="960px"
margin="0 auto"
>
<Flex alignItems="center" justifyContent="space-between">
<Text fontSize="lg">You're editing the overview categories.</Text>
<Flex>
<Text fontSize="lg" fontWeight="bold" marginLeft={4}>
{changes}
</Text>
<Text fontSize="lg" marginLeft={2}>
changes made to 'categories.json'
</Text>
</Flex>
<Button onClick={handleSubmit} colorScheme="red" variant="solid">
Submit Pull-request
</Button>
</Flex>
</Box>
<Modal isOpen={modalOpen} onClose={onClose} size="xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>Nice changes!</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Text>To submit those changes, follow these steps:</Text>
<Text fontWeight="bold" mt={4}>
Step 1:
</Text>
<Text mt={2} mb={2}>
Copy all the code,
</Text>
<CopyButton copyText={categoryCode} buttonText="Copy code" mr={4} />
<Text fontWeight="bold" mt={6}>
Step 2:
</Text>
<Text mt={2} mb={4}>
Open Pull-request, select-all and paste the code with the changes.
</Text>
<Button as="a" variant="solid" href={openPullRequestUrl} target="__blank">
Open pull-request
</Button>
<Divider mt={8} mb={4} />
<Text fontWeight="bold" mt={6} mb={4}>
Code:
</Text>
<Button as="a" variant="solid" onClick={() => setShowCode(show => !show)}>
Show code
</Button>
{showCode && (
<Box maxHeight={320} overflow="auto" background="gray.800">
<CodeBlock code={categoryCode} language="json" showLines />
</Box>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={onClose}>
Done
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default CategoryChangesBar;

View File

@@ -2,7 +2,6 @@ import { Box, BoxProps, chakra, useColorMode } from '@chakra-ui/react';
import nightOwlLightTheme from 'prism-react-renderer/themes/nightOwlLight'; import nightOwlLightTheme from 'prism-react-renderer/themes/nightOwlLight';
import nightOwlDarkTheme from 'prism-react-renderer/themes/nightOwl'; import nightOwlDarkTheme from 'prism-react-renderer/themes/nightOwl';
import uiTheme from '../lib/theme'; import uiTheme from '../lib/theme';
// import theme from 'prism-react-renderer/themes/nightOwl';
import BaseHighlight, { defaultProps, Language } from 'prism-react-renderer'; import BaseHighlight, { defaultProps, Language } from 'prism-react-renderer';
import { CSSProperties } from 'react'; import { CSSProperties } from 'react';
import CopyButton from './CopyButton'; import CopyButton from './CopyButton';
@@ -49,7 +48,7 @@ function CodeBlock({ code, language, metastring, showLines, ...props }: Highligh
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const backgroundColor = const backgroundColor =
colorMode === 'light' ? uiTheme.colors.gray[100] : uiTheme.colors.gray[700]; colorMode === 'light' ? uiTheme.colors.gray[100] : uiTheme.colors.gray[900];
const codeTheme = colorMode === 'light' ? nightOwlLightTheme : nightOwlDarkTheme; const codeTheme = colorMode === 'light' ? nightOwlLightTheme : nightOwlDarkTheme;
const customizedCodeTheme = { const customizedCodeTheme = {
@@ -63,6 +62,8 @@ function CodeBlock({ code, language, metastring, showLines, ...props }: Highligh
return ( return (
<Box position="relative" zIndex="0" {...props}> <Box position="relative" zIndex="0" {...props}>
<CodeContainer bg={backgroundColor}> <CodeContainer bg={backgroundColor}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<BaseHighlight <BaseHighlight
{...defaultProps} {...defaultProps}
code={code} code={code}

View File

@@ -4,7 +4,7 @@ const CopyButton = ({ copyText, buttonText = 'copy', ...props }) => {
const { hasCopied, onCopy } = useClipboard(copyText); const { hasCopied, onCopy } = useClipboard(copyText);
return ( return (
<Button onClick={onCopy} {...props}> <Button onClick={onCopy} {...props} variant="solid">
{hasCopied ? 'copied' : buttonText} {hasCopied ? 'copied' : buttonText}
</Button> </Button>
); );

View File

@@ -59,10 +59,21 @@ export function CustomizeIconContext({ children }): JSX.Element {
return <IconStyleContext.Provider value={value}>{children}</IconStyleContext.Provider>; return <IconStyleContext.Provider value={value}>{children}</IconStyleContext.Provider>;
} }
export function useCustomizeIconContext(): ICustomIconStyle { export function useCustomizeIconContext() {
const context = useContext(IconStyleContext); const context = useContext(IconStyleContext);
if (context === undefined) { if (context === undefined) {
throw new Error('useCustomizeIconContext must be used within a IconStyleContextProvider'); return {
color: 'currentColor',
size: 24,
strokeWidth: 2,
iconsRef: { current: {} },
/* eslint-disable @typescript-eslint/no-empty-function */
setStroke: function() {},
setColor: function() {},
setSize: function() {},
resetStyle: function() {},
/* eslint-enable @typescript-eslint/no-empty-function */
};
} }
return context; return context;
} }

View File

@@ -1,4 +1,4 @@
import { Button, Flex, Link, WrapItem, Text, Wrap, Heading } from '@chakra-ui/react'; import { Button, Flex, Link, WrapItem, Text, Wrap, Heading, Box } from '@chakra-ui/react';
import download from 'downloadjs'; import download from 'downloadjs';
import { Download, Github } from 'lucide-react'; import { Download, Github } from 'lucide-react';
import NextLink from 'next/link'; import NextLink from 'next/link';
@@ -11,25 +11,10 @@ import PreactLogo from '../../public/framework-logos/preact.svg';
import AngularLogo from '../../public/framework-logos/angular.svg'; import AngularLogo from '../../public/framework-logos/angular.svg';
import FlutterLogo from '../../public/framework-logos/flutter.svg'; import FlutterLogo from '../../public/framework-logos/flutter.svg';
import SvelteLogo from '../../public/framework-logos/svelte.svg'; import SvelteLogo from '../../public/framework-logos/svelte.svg';
import { useState } from 'react'; import { useCallback, useState } from 'react';
import { useCustomizeIconContext } from './CustomizeIconContext'; import { useCustomizeIconContext } from './CustomizeIconContext';
import { IconEntity } from '../types'; import { IconEntity } from '../types';
import generateZip, { IconContent } from 'src/lib/generateZip';
type IconContent = [icon: string, src:string];
async function generateZip(icons: IconContent[]) {
const JSZip = (await import('jszip')).default
const zip = new JSZip();
const addingZipPromises = icons.map(([name, src]) =>
zip.file(`${name}.svg`, src),
);
await Promise.all(addingZipPromises)
return zip.generateAsync({ type: 'blob' });
}
interface HeaderProps { interface HeaderProps {
data: IconEntity[]; data: IconEntity[];
@@ -37,25 +22,27 @@ interface HeaderProps {
const Header = ({ data }: HeaderProps) => { const Header = ({ data }: HeaderProps) => {
const [zippingIcons, setZippingIcons] = useState(false); const [zippingIcons, setZippingIcons] = useState(false);
const { iconsRef } = useCustomizeIconContext(); const { iconsRef, strokeWidth, color, size } = useCustomizeIconContext();
const downloadAllIcons = async () => { const downloadAllIcons = useCallback(async () => {
setZippingIcons(true); setZippingIcons(true);
let iconEntries: IconContent[] = Object.entries(iconsRef.current).map(([name, svgEl]) => [ let iconEntries: IconContent[] = Object.entries(iconsRef.current)
.map(([name, svgEl]) => [
name, name,
svgEl.outerHTML, svgEl.outerHTML,
]); ]);
// Fallback // Fallback
if (iconEntries.length === 0) { if (iconEntries.length === 0) {
iconEntries = data.map(icon => [icon.name, icon.src]); const getFallbackZip = (await import('../lib/getFallbackZip')).default
iconEntries = getFallbackZip(data, { strokeWidth, color, size })
} }
const zip = await generateZip(iconEntries); const zip = await generateZip(iconEntries);
download(zip, 'lucide.zip'); download(zip, 'lucide.zip');
setZippingIcons(false); setZippingIcons(false);
}; }, []);
const repositoryUrl = 'https://github.com/lucide-icons/lucide'; const repositoryUrl = 'https://github.com/lucide-icons/lucide';
@@ -117,7 +104,8 @@ const Header = ({ data }: HeaderProps) => {
]; ];
return ( return (
<Flex direction="column" align="center" justify="center"> <Box maxW="1250px" mx="auto">
<Flex direction="column" align="center" justify="center" py={12}>
<Heading as="h1" fontSize="4xl" mb="4" textAlign="center"> <Heading as="h1" fontSize="4xl" mb="4" textAlign="center">
Beautiful &amp; consistent icon toolkit made by the community. Beautiful &amp; consistent icon toolkit made by the community.
</Heading> </Heading>
@@ -152,7 +140,7 @@ const Header = ({ data }: HeaderProps) => {
{packages.map(({ name, href, Logo, label }) => ( {packages.map(({ name, href, Logo, label }) => (
<WrapItem key={name}> <WrapItem key={name}>
<NextLink href={href} key={name} passHref> <NextLink href={href} key={name} passHref>
<Link _hover={{ opacity: 0.8 }} aria-label={label} title={label}> <Link _hover={{ opacity: 0.8 }} aria-label={label}>
<Logo /> <Logo />
</Link> </Link>
</NextLink> </NextLink>
@@ -194,6 +182,7 @@ const Header = ({ data }: HeaderProps) => {
</WrapItem> </WrapItem>
</Wrap> </Wrap>
</Flex> </Flex>
</Box>
); );
}; };

View File

@@ -0,0 +1,45 @@
import { Box, Text, useColorModeValue, BoxProps } from '@chakra-ui/react';
import { forwardRef } from 'react';
import theme from '../lib/theme';
interface IconCategoryProps extends BoxProps {
active?: boolean;
name: string;
dragging: boolean;
}
const IconCategory = forwardRef<HTMLDivElement, IconCategoryProps>(
({ name, active = false, dragging, children, ...props }: IconCategoryProps, ref) => {
const activeBackground = useColorModeValue(theme.colors.gray, theme.colors.gray[700]);
const toTitleCase = string =>
string
.split(' ')
.map(word => word[0].toUpperCase() + word.slice(1))
.join(' ');
return (
<Box
backgroundColor={active ? activeBackground : 'transparent'}
borderRadius={8}
ref={ref}
pointerEvents="all"
padding={4}
marginBottom={3}
transition="background 120ms ease-in"
_hover={{
background: dragging && activeBackground,
}}
{...props}
>
<Box>
<Text fontSize="xl" marginBottom={3}>
{toTitleCase(name)}
</Text>
{children}
</Box>
</Box>
);
},
);
export default IconCategory;

View File

@@ -0,0 +1,168 @@
import { Box, BoxProps, Button, Divider, Drawer, DrawerBody, DrawerCloseButton, DrawerContent, DrawerHeader, DrawerOverlay, useBreakpointValue, useTheme, useColorModeValue } from "@chakra-ui/react"
import { motion } from "framer-motion"
import { Fragment, useMemo } from "react"
import {Category, IconEntity} from "src/types"
import {createLucideIcon} from "lucide-react";
const ListWrapper = ({ children, ...restProps }: BoxProps) => {
return (
<Box
w="full"
h="full"
overflowY="auto"
sx={{
direction: 'rtl',
'&::-webkit-scrollbar' : {
width: '4px',
},
'&::-webkit-scrollbar-track' : {
background: 'transparent',
},
'&::-webkit-scrollbar-thumb' : {
bgColor: useColorModeValue('var(--chakra-colors-gray-300)', 'var(--chakra-colors-whiteAlpha-300)'),
borderRadius: 0,
},
}}
{...restProps}
>
<Box
whiteSpace="nowrap"
height="100%"
display="flex"
flexDirection="column"
paddingBottom={8}
paddingX={2}
paddingY={1}
paddingRight={4}
sx={{
direction: 'ltr'
}}
>
{ children }
</Box>
</Box>
)
}
const CATEGORY_TOP_OFFSET = 100
interface IconCategoryDrawerProps {
data: IconEntity[];
categories: Category[]
setCategoryView: (view: boolean) => void
open: boolean
onClose: () => void
}
const IconCategoryDrawer = ({ open, onClose, categories, data, setCategoryView }: IconCategoryDrawerProps) => {
const theme = useTheme()
const useCustomDrawer = useBreakpointValue({ base: false, md: true });
const sidebarVariants = {
closed: {
width: 0,
},
open: {
width: theme.sizes['xs']
}
}
const categoryList = useMemo(() => {
return (
<>
{[{ name: 'all', title: 'All', icon: null, iconCount: data.length }, ...categories].map(({ title, name, icon, iconCount }) => {
// Show category icon?
const iconData = data.find(({ name: iconName }) => iconName === icon)
const Icon = iconData ? createLucideIcon(iconData.name, iconData.iconNode) : null
return (
<Fragment key={name}>
<Button
as="a"
colorScheme='gray'
variant='ghost'
width="100%"
justifyContent="space-between"
leftIcon={Icon ? <Icon /> : null}
onClick={(event) => {
event.stopPropagation()
if (!useCustomDrawer) {
onClose()
const [routePath] = window.location.href.split('#')
setTimeout(() =>{
window.location.href = `${routePath}#${name}`
},150)
}
setCategoryView(name !== 'all')
}}
href={useCustomDrawer ? `#${name}` : undefined}
marginBottom={1}
sx={{
opacity: .7,
flexShrink: 0,
'&.active': {
color: 'brand.500'
}
}}
>
<Box width='100%'>{title}</Box>
<Box sx={{opacity: .5}}><small>{iconCount}</small></Box>
</Button>
{name === 'all' && (
<Divider marginY={2} />
)}
</Fragment>
)
})}
<Box h={8} flexShrink={0}></Box>
</>
)
}, [categories, useCustomDrawer])
if(useCustomDrawer) {
return (
<motion.div
variants={sidebarVariants}
animate={(open ?? useCustomDrawer) ? 'open' : 'closed'}
initial={false}
style={{
height: `calc(100vh - ${CATEGORY_TOP_OFFSET}px)`,
position: 'sticky',
top: '100px'
}}
>
<ListWrapper>
{categoryList}
</ListWrapper>
</motion.div>
)
}
return (
<Drawer
placement="left"
onClose={onClose}
isOpen={open}
size="sm"
blockScrollOnMount={false}
>
<DrawerOverlay />
<DrawerContent>
<DrawerCloseButton marginTop={3.5} marginRight={3} />
<DrawerHeader />
<DrawerBody>
<ListWrapper>
{categoryList}
</ListWrapper>
</DrawerBody>
</DrawerContent>
</Drawer>
)
}
export default IconCategoryDrawer

View File

@@ -0,0 +1,78 @@
import { Box, BoxProps, Stack, Text, useColorModeValue } from '@chakra-ui/react';
import { useMemo } from 'react';
import { Category, IconEntity } from 'src/types';
import theme from '../lib/theme';
import IconList from './IconList';
interface IconCategoryProps {
icons: IconEntity[]
data: IconEntity[]
categories: Category[]
categoryProps?: {
innerProps: BoxProps,
activeCategory: string | null
}
}
const IconCategory = ({
icons,
data,
categories = [],
categoryProps = {
innerProps: {},
activeCategory: null,
},
}: IconCategoryProps) => {
const { innerProps, activeCategory, ...outerProps } = categoryProps;
const activeBackground = useColorModeValue(theme.colors.gray, theme.colors.gray[700]);
const iconCategories = useMemo(
() =>
categories.reduce((categoryMap, { name, title }) => {
const categoryIcons = data.filter(({categories}) => categories.includes(name))
const isSearching = icons.length !== data.length;
const searchResults = isSearching
? categoryIcons.filter(icon => icons.some((item) => item?.name === icon?.name))
: categoryIcons;
categoryMap.push({
title,
name,
icons: searchResults,
isActive: name === activeCategory,
});
return categoryMap;
}, []),
[icons, categories, activeCategory],
);
return (
<Stack spacing={4}>
{iconCategories
.filter(({ icons }) => icons.length)
.map(({ name, title, icons, isActive }) => (
<Box
key={name}
backgroundColor={isActive ? activeBackground : 'transparent'}
borderRadius={8}
{...outerProps}
>
<Box {...innerProps || {}}>
<Text fontSize="xl" marginBottom={3} id={name} sx={{
'&:target': {
scrollMarginTop: 20
}
}}>
{title}
</Text>
<IconList icons={icons} category={name}/>
</Box>
</Box>
))}
</Stack>
);
};
export default IconCategory;

View File

@@ -1,5 +1,5 @@
import { useContext, useState } from 'react'; import { useState } from 'react';
import { IconStyleContext } from './CustomizeIconContext'; import { useCustomizeIconContext } from './CustomizeIconContext';
import { Edit } from 'lucide-react'; import { Edit } from 'lucide-react';
import { import {
Button, Button,
@@ -8,30 +8,56 @@ import {
DrawerCloseButton, DrawerCloseButton,
DrawerContent, DrawerContent,
DrawerHeader, DrawerHeader,
DrawerOverlay,
FormControl, FormControl,
FormLabel, FormLabel,
Grid, Grid,
Hide,
IconButton,
Show,
Slider, Slider,
SliderFilledTrack, SliderFilledTrack,
SliderThumb, SliderThumb,
SliderTrack, SliderTrack,
Flex, Flex,
Text, Text,
ButtonProps,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import ColorPicker from './ColorPicker'; import ColorPicker from './ColorPicker';
export function IconCustomizerDrawer() { export const IconCustomizerDrawer = (props: ButtonProps) => {
const [showCustomize, setShowCustomize] = useState(false); const [showCustomize, setShowCustomize] = useState(false);
const { color, setColor, size, setSize, strokeWidth, setStroke, resetStyle } = useContext(IconStyleContext); const {
color,
setColor,
size,
setSize,
strokeWidth,
setStroke,
resetStyle,
} = useCustomizeIconContext();
return ( return (
<> <>
<Button as="a" leftIcon={<Edit />} size="lg" onClick={() => setShowCustomize(true)}> <Hide below='md'>
Customize <Button
</Button> as="a"
<Drawer isOpen={showCustomize} placement="right" onClose={() => setShowCustomize(false)}> leftIcon={<Edit />}
<DrawerOverlay /> size="lg"
onClick={() => setShowCustomize(true)}
{...props}
>Customize</Button>
</Hide>
<Show below='md'>
<IconButton
aria-label='Customize'
variant="solid"
color="current"
onClick={() => setShowCustomize(true)}
icon={<Edit />}
{...props}
></IconButton>
</Show>
<Drawer isOpen={showCustomize} placement="right" onClose={() => setShowCustomize(false)} size="md">
<DrawerContent> <DrawerContent>
<DrawerCloseButton /> <DrawerCloseButton />
<DrawerHeader>Customize Icons</DrawerHeader> <DrawerHeader>Customize Icons</DrawerHeader>
@@ -42,7 +68,7 @@ export function IconCustomizerDrawer() {
<ColorPicker <ColorPicker
color={color} color={color}
value={color} value={color}
onChangeComplete={(col) => setColor(col.hex)} onChangeComplete={col => setColor(col.hex)}
/> />
</FormControl> </FormControl>
<FormControl> <FormControl>
@@ -93,4 +119,4 @@ export function IconCustomizerDrawer() {
</Drawer> </Drawer>
</> </>
); );
} };

View File

@@ -2,11 +2,11 @@ import { Box, Text, IconButton, useColorMode, Flex, Slide, ButtonGroup, Button,
import theme from "../lib/theme"; import theme from "../lib/theme";
import download from 'downloadjs'; import download from 'downloadjs';
import { X as Close } from 'lucide-react'; import { X as Close } from 'lucide-react';
import {useContext, useEffect, useRef} from "react"; import { useEffect, useRef } from "react";
import {IconStyleContext} from "./CustomizeIconContext"; import {useCustomizeIconContext} from "./CustomizeIconContext";
import {IconWrapper} from "./IconWrapper";
import ModifiedTooltip from "./ModifiedTooltip"; import ModifiedTooltip from "./ModifiedTooltip";
import { IconEntity } from "../types"; import { IconEntity } from "../types";
import { createLucideIcon } from 'lucide-react';
type IconDownload = { type IconDownload = {
src: string; src: string;
@@ -14,15 +14,17 @@ type IconDownload = {
}; };
interface IconDetailOverlayProps { interface IconDetailOverlayProps {
open: boolean open?: boolean
close: () => void close?: () => void
icon?: IconEntity icon?: IconEntity
} }
const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps) => { const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps) => {
const toast = useToast(); const toast = useToast();
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const {color, strokeWidth, size} = useContext(IconStyleContext);
const { tags = [], name, iconNode } = icon ?? {};
const {color, strokeWidth, size} = useCustomizeIconContext();
const iconRef = useRef<SVGSVGElement>(null); const iconRef = useRef<SVGSVGElement>(null);
const [isMobile] = useMediaQuery("(max-width: 560px)") const [isMobile] = useMediaQuery("(max-width: 560px)")
const { isOpen, onOpen, onClose } = useDisclosure() const { isOpen, onOpen, onClose } = useDisclosure()
@@ -37,8 +39,6 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
return null return null
} }
const { tags = [], name = '' } = icon;
const handleClose = () => { const handleClose = () => {
onClose(); onClose();
close(); close();
@@ -54,10 +54,12 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
color: color, color: color,
}; };
const downloadIcon = ({src, name = ''} : IconDownload) => download(src, `${name}.svg`, 'image/svg+xml'); const Icon = createLucideIcon(name, iconNode)
const copyIcon = async ({src, name} : IconDownload) => { const downloadIcon = ({name = ''} : IconDownload) => download(iconRef.current.outerHTML, `${name}.svg`, 'image/svg+xml');
const trimmedSrc = src.replace(/(\r\n|\n|\r|\s\s)/gm, "")
const copyIcon = async ({name} : IconDownload) => {
const trimmedSrc = iconRef.current.outerHTML.replace(/(\r\n|\n|\r|\s\s)/gm, "")
await navigator.clipboard.writeText(trimmedSrc) await navigator.clipboard.writeText(trimmedSrc)
@@ -70,14 +72,14 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
}); });
} }
const downloadPNG = ({src, name}: IconDownload) => { const downloadPNG = ({name}: IconDownload) => {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = size;
canvas.height = size; canvas.height = size;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const image = new Image(); const image = new Image();
image.src = `data:image/svg+xml;base64,${btoa(src)}`; image.src = `data:image/svg+xml;base64,${btoa(iconRef.current.outerHTML)}`;
image.onload = function() { image.onload = function() {
ctx.drawImage(image, 0, 0); ctx.drawImage(image, 0, 0);
@@ -98,7 +100,7 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
height={0} height={0}
key={name} key={name}
> >
<Slide direction="bottom" in={isOpen} style={{ zIndex: 10 }}> <Slide direction="bottom" in={isOpen} style={{ zIndex: 10, pointerEvents: 'none' }}>
<Flex <Flex
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
@@ -120,6 +122,7 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
? theme.colors.white ? theme.colors.white
: theme.colors.gray[700] : theme.colors.gray[700]
} }
style={{ pointerEvents: 'initial' }}
padding={8} padding={8}
> >
<IconButton <IconButton
@@ -151,14 +154,13 @@ const IconDetailOverlay = ({ open = true, close, icon }: IconDetailOverlayProps)
style={iconStyling} style={iconStyling}
className="icon-large" className="icon-large"
> >
<IconWrapper <Icon
src={icon.src}
stroke={color} stroke={color}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
height={size} size={size}
width={size}
ref={iconRef} ref={iconRef}
/> />
</div> </div>
<svg className="icon-grid" width="24" height="24" viewBox={`0 0 ${size} ${size}`} fill="none" stroke={colorMode == "light" ? '#E2E8F0' : theme.colors.gray[600]} strokeWidth="0.1" xmlns="http://www.w3.org/2000/svg"> <svg className="icon-grid" width="24" height="24" viewBox={`0 0 ${size} ${size}`} fill="none" stroke={colorMode == "light" ? '#E2E8F0' : theme.colors.gray[600]} strokeWidth="0.1" xmlns="http://www.w3.org/2000/svg">

View File

@@ -1,35 +1,19 @@
import { Grid } from '@chakra-ui/react'; import { Grid } from '@chakra-ui/react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { memo } from 'react'; import { memo } from 'react';
import IconListItem from './IconListItem'; import IconListItem from './IconListItem';
import { IconEntity } from '../types'; import { IconEntity } from '../types';
interface IconListProps { interface IconListProps {
icons: IconEntity[]; icons: IconEntity[];
category?: string
} }
const IconList = memo(({ icons }: IconListProps) => { const IconList = memo(({ icons, category = '' }: IconListProps) => {
const router = useRouter();
return ( return (
<Grid templateColumns={`repeat(auto-fill, minmax(150px, 1fr))`} gap={5} marginBottom="320px"> <Grid templateColumns={`repeat(auto-fill, minmax(80px, 1fr))`} gap={5} marginBottom={6} justifyItems={'center'}>
{icons.map(icon => { {icons.map(icon => {
return ( return (
<Link <IconListItem {...icon} key={`${category}-${icon.name}`}/>
key={icon.name}
scroll={false}
shallow={true}
href={{
pathname: '/icon/[iconName]',
query: {
...router.query,
iconName: icon.name,
},
}}
>
<IconListItem {...icon} />
</Link>
); );
})} })}
</Grid> </Grid>

View File

@@ -1,21 +1,25 @@
import { Button, ButtonProps, Flex, Text, useToast } from '@chakra-ui/react'; import { Button, ButtonProps, Tooltip, useToast } from '@chakra-ui/react';
import download from 'downloadjs'; import download from 'downloadjs';
import { memo } from 'react'; import { memo, useCallback } from 'react';
import { createLucideIcon, IconNode } from 'lucide-react';
import { useCustomizeIconContext } from './CustomizeIconContext'; import { useCustomizeIconContext } from './CustomizeIconContext';
import { IconWrapper } from './IconWrapper'; import { useRouter } from 'next/router';
interface IconListItemProps { interface IconListItemProps extends ButtonProps {
name: string; name: string;
onClick?: ButtonProps['onClick'] iconNode: IconNode;
src: string;
} }
const IconListItem = ({ name, onClick, src: svg }: IconListItemProps) => { const IconListItem = ({ name, iconNode }: IconListItemProps) => {
const router = useRouter()
const toast = useToast(); const toast = useToast();
const { color, size, strokeWidth, iconsRef } = useCustomizeIconContext(); const { color, size, strokeWidth, iconsRef } = useCustomizeIconContext();
const handleClick:ButtonProps['onClick'] = async (event) => { const Icon = createLucideIcon(name, iconNode)
const src = (iconsRef.current[name].outerHTML ?? svg).replace(/(\r\n|\n|\r|(>\s\s<))/gm, "")
const handleClick:ButtonProps['onClick'] = useCallback(async (event) => {
const src = (iconsRef.current[name].outerHTML).replace(/(\r\n|\n|\r|(>\s\s<))/gm, "")
if (event.shiftKey) { if (event.shiftKey) {
await navigator.clipboard.writeText(src) await navigator.clipboard.writeText(src)
@@ -25,46 +29,53 @@ const IconListItem = ({ name, onClick, src: svg }: IconListItemProps) => {
status: 'success', status: 'success',
duration: 1500, duration: 1500,
}); });
return
} }
if (event.altKey) { if (event.altKey) {
download(src, `${name}.svg`, 'image/svg+xml'); download(src, `${name}.svg`, 'image/svg+xml');
} return
if (onClick) {
onClick(event);
}
} }
router.push({
pathname: `/icon/${name}`,
query: (
router.query?.search != null
? { search: router.query.search }
: {}
),
},
undefined,
{
shallow: true,
scroll: false
})
}, [iconsRef, name, router, toast])
return ( return (
<Tooltip label={name}>
<Button <Button
as="a"
variant="ghost" variant="ghost"
borderWidth="1px" borderWidth="1px"
rounded="lg" rounded="lg"
padding={2} padding={2}
height={32} height={20}
width={20}
position="relative" position="relative"
whiteSpace="normal" whiteSpace="normal"
onClick={handleClick}
key={name}
alignItems="center" alignItems="center"
sx={{cursor: 'pointer'}}
onClick={handleClick}
> >
<Flex direction="column" align="center" justify="stretch" width="100%" gap={4}> <Icon
<Flex flex={2} flexBasis="100%" minHeight={10} align="flex-end"> ref={iconEl => (iconsRef.current[name] = iconEl)}
<IconWrapper size={size}
src={svg}
stroke={color} stroke={color}
strokeWidth={strokeWidth} strokeWidth={strokeWidth}
height={size}
width={size}
ref={iconEl => (iconsRef.current[name] = iconEl)}
/> />
</Flex>
<Flex flex={1} minHeight={10} align="center">
<Text wordBreak="break-word" maxWidth="100%">
{name}
</Text>
</Flex>
</Flex>
</Button> </Button>
</Tooltip>
); );
}; };

View File

@@ -1,16 +1,38 @@
import { Box, Text } from '@chakra-ui/react'; import { Box, Text, IconButton, HStack } from '@chakra-ui/react';
import React, { useState } from 'react'; import React, { memo, useEffect, useState } from 'react';
import useSearch from '../lib/useSearch'; import useSearch from '../lib/useSearch';
import IconList from './IconList'; import IconList from './IconList';
import { SearchInput } from './SearchInput'; import { SearchInput } from './SearchInput';
import { IconEntity } from '../types'; import { Category, IconEntity } from '../types';
import { SidebarClose, SidebarOpen } from 'lucide-react';
import IconCategoryList from './IconCategoryList';
import { IconCustomizerDrawer } from './IconCustomizerDrawer';
import IconCategoryDrawer from './IconCategoryDrawer';
interface IconOverviewProps { interface IconOverviewProps {
data: IconEntity[]; data: IconEntity[];
categories: Category[]
} }
const IconOverview = ({ data }: IconOverviewProps) => { const IconOverview = ({ data, categories }: IconOverviewProps): JSX.Element => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [sidebarOpen, setSidebarOpen] = useState<boolean | undefined>();
const [categoryView, setCategoryView] = useState(false);
useEffect(() => {
if(
typeof window !== 'undefined' &&
window.location.href.split('#').length === 2
) {
const [,hash] = window.location.href.split('#')
setCategoryView(categories.some(({ name }) => name === hash))
}
}, [])
const SidebarIcon = sidebarOpen ? SidebarOpen : SidebarClose;
const searchResults = useSearch(query, data, [ const searchResults = useSearch(query, data, [
{ name: 'name', weight: 2 }, { name: 'name', weight: 2 },
@@ -18,20 +40,49 @@ const IconOverview = ({ data }: IconOverviewProps) => {
]); ]);
return ( return (
<> <Box>
<SearchInput onChange={setQuery} count={data.length} /> <HStack position="sticky" top={0} zIndex={1} gap={2} padding={5}>
<IconButton
aria-label="Close overlay"
variant="solid"
color="current"
onClick={() => setSidebarOpen(currentView => {
if(currentView == null) {
return false
}
<Box marginTop={5}> return !currentView
})}
icon={<SidebarIcon />}
/>
<SearchInput onChange={setQuery} count={data.length} />
<IconCustomizerDrawer size="md" paddingX={6} />
</HStack>
<HStack marginBottom="320px" padding={5} alignItems="flex-start">
<IconCategoryDrawer
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
categories={categories}
data={data}
setCategoryView={setCategoryView}
/>
<Box flex={1} paddingTop={1}>
{searchResults.length > 0 ? ( {searchResults.length > 0 ? (
categoryView ? (
<IconCategoryList icons={searchResults} data={data} categories={categories} />
) : (
<IconList icons={searchResults} /> <IconList icons={searchResults} />
)
) : ( ) : (
<Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word"> <Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word">
No results found for "{query}" No results found for "{query}"
</Text> </Text>
)} )}
</Box> </Box>
</> </HStack>
</Box>
); );
}; };
export default IconOverview; export default memo(IconOverview)

View File

@@ -0,0 +1,66 @@
import { Box, BoxProps } from '@chakra-ui/react';
import { AnimatePresence, Reorder } from 'framer-motion';
import { memo, RefObject } from 'react';
import { IconEntity } from '../types';
import IconReorderItem from './IconReorderItem';
interface IconListProps {
icons: IconEntity[];
setIcons: (icons) => void;
dropZones?: RefObject<[string, HTMLDivElement][]>;
onDrop?: (name: string, category: string) => void;
dragging: boolean;
setDragging: (dragging) => void;
sx?: BoxProps['sx'];
}
const IconReorder = ({
icons,
setIcons,
dropZones,
onDrop,
dragging,
setDragging,
sx,
}: IconListProps) => {
return (
<Box
as={Reorder.Group}
display="flex"
flexWrap="wrap"
gap={5}
marginBottom={6}
onReorder={setIcons}
layoutScroll
values={icons.map(({ name }) => name)}
sx={{
'.dragging': {
position: 'absolute',
},
...sx,
}}
>
<AnimatePresence>
{icons.map(icon => {
return (
<IconReorderItem
icon={icon}
key={icon.name}
dropZones={dropZones}
onDrop={onDrop}
dragging={dragging}
setDragging={setDragging}
/>
);
})}
</AnimatePresence>
</Box>
);
};
export default memo(IconReorder, (prevProps, nextProps) => {
const prevIconsNames = prevProps.icons.map(({ name }) => name);
const nextIconsNames = nextProps.icons.map(({ name }) => name);
return JSON.stringify(prevIconsNames) === JSON.stringify(nextIconsNames);
});

View File

@@ -0,0 +1,78 @@
import { useState } from 'react';
import { useMotionValue, Reorder } from 'framer-motion';
import useRaisedShadow from '../hooks/useRaisedShadow';
import IconListItem from './IconListItem';
import { IconEntity } from '../types';
import { RefObject } from 'react';
interface Props {
icon: IconEntity
dropZones?: RefObject<[string, HTMLDivElement][]>
onDrop?: (name:string, category: string) => void
dragging: boolean;
setDragging: (dragging) => void;
}
const IconReorderItem = ({ icon, dropZones, onDrop, setDragging }: Props): JSX.Element => {
const y = useMotionValue(0);
const boxShadow = useRaisedShadow(y);
const [dragItem, setDragItem] = useState(false);
const onDragEnd = (event) => {
setDragItem(false)
setDragging(false);
const dropZone = dropZones.current?.find(([, el]) => {
if (!Array.isArray(event?.path)) {
return false
}
return Array.from(event.path).includes(el)
})
if (dropZone?.[0] && onDrop) {
const category = dropZone?.[0]
console.log(icon.name, category);
onDrop(icon.name, category)
}
};
const onDrag = () => {
setDragItem(true)
setDragging(true);
};
return (
<Reorder.Item
value={icon.name}
style={{
boxShadow, y,
listStyle: 'none',
pointerEvents: dragItem ? 'none' : 'all',
cursor: dragItem ? 'grab': 'pointer',
}}
drag
dragConstraints={{ top: 0, left: 0, right: 0, bottom: 0 }}
dragElastic={1}
onDragEnd={onDragEnd}
onDrag={onDrag}
initial={{
opacity: 0,
scale: 0,
}}
animate={{
opacity: 1,
scale: 1,
transition: {
duration: 0.2,
delay: 0.02,
},
}}
>
<IconListItem {...icon} />
</Reorder.Item>
);
};
export default IconReorderItem;

View File

@@ -15,6 +15,7 @@ import NextLink from 'next/link';
import { Moon, Sun, Menu, X } from 'lucide-react'; import { Moon, Sun, Menu, X } from 'lucide-react';
import { useMobileNavigationContext, useMobileNavigationValue } from './MobileNavigationProvider'; import { useMobileNavigationContext, useMobileNavigationValue } from './MobileNavigationProvider';
import Logo from './Logo'; import Logo from './Logo';
import menuItems from '../static/menuItems';
interface LayoutProps extends BoxProps { interface LayoutProps extends BoxProps {
aside?: BoxProps['children']; aside?: BoxProps['children'];
@@ -37,12 +38,14 @@ const Layout = ({ aside, children }: LayoutProps) => {
}; };
function setQuery(query) { function setQuery(query) {
router.push({ router.push(
{
pathname: '/', pathname: '/',
query: { query: query }, query: { query: query },
}, },
undefined, undefined,
{ shallow: true }) { shallow: true },
);
} }
useKeyBindings({ useKeyBindings({
@@ -56,7 +59,7 @@ const Layout = ({ aside, children }: LayoutProps) => {
return ( return (
<Box h="100vh"> <Box h="100vh">
<Flex mb={16} w="full"> <Flex w="full">
<Flex <Flex
alignItems="center" alignItems="center"
justifyContent="space-between" justifyContent="space-between"
@@ -72,29 +75,28 @@ const Layout = ({ aside, children }: LayoutProps) => {
<Flex justifyContent="center" alignItems="center"> <Flex justifyContent="center" alignItems="center">
{showBaseNavigation ? ( {showBaseNavigation ? (
<> <>
<NextLink href="/docs" passHref> {menuItems.map(menuItem => {
<Link marginRight={12} fontSize="xl"> if (menuItem.isExternal) {
Documentation return (
</Link>
</NextLink>
<NextLink href="/packages" passHref>
<Link marginRight={12} fontSize="xl">
Packages
</Link>
</NextLink>
<NextLink href="/license" passHref>
<Link marginRight={12} fontSize="xl">
License
</Link>
</NextLink>
<Link <Link
href="https://github.com/lucide-icons/lucide" href={menuItem.href}
isExternal isExternal
marginRight={6} marginRight={6}
fontSize="xl" fontSize="lg"
key={menuItem.name}
> >
Github {menuItem.name}
</Link> </Link>
);
}
return (
<NextLink href={menuItem.href} passHref key={menuItem.name}>
<Link marginRight={8} fontSize="lg">
{menuItem.name}
</Link>
</NextLink>
);
})}
</> </>
) : null} ) : null}
<IconButton <IconButton
@@ -115,8 +117,12 @@ const Layout = ({ aside, children }: LayoutProps) => {
</Flex> </Flex>
</Flex> </Flex>
<Flex> <Flex>
{aside ? <Box as="aside" marginRight={{ base: 0, lg: -240, }}>{aside}</Box> : null} {aside ? (
<Flex margin="0 auto" direction="column" maxW="1250px" px={5} width="100%"> <Box as="aside" marginRight={{ base: 0, lg: -240 }}>
{aside}
</Box>
) : null}
<Flex margin="0 auto" direction="column" width="100%">
{children} {children}
<Divider mb={6} mt={12} /> <Divider mb={6} mt={12} />
<p style={{ alignSelf: 'center' }}> <p style={{ alignSelf: 'center' }}>

View File

@@ -12,6 +12,7 @@ import {
import NextLink from 'next/link'; import NextLink from 'next/link';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { ReactNode, useEffect } from 'react'; import { ReactNode, useEffect } from 'react';
import menuItems from '../static/menuItems';
import Logo from './Logo'; import Logo from './Logo';
import { useMobileNavigationContext } from './MobileNavigationProvider'; import { useMobileNavigationContext } from './MobileNavigationProvider';
@@ -35,21 +36,9 @@ const MobileMenu = ({ children }: { children?: ReactNode }): JSX.Element => {
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
<Box mb={4}> <Box mb={4}>
<NextLink href="/docs" passHref> {menuItems.map(menuItem => {
<Link fontSize="lg" fontWeight="bold" display="block" mb={2}> if (menuItem.isExternal) {
Documentation return (
</Link>
</NextLink>
<NextLink href="/packages" passHref>
<Link marginRight={12} fontSize="lg" fontWeight="bold" display="block" mb={2}>
Packages
</Link>
</NextLink>
<NextLink href="/license" passHref>
<Link marginRight={12} fontSize="xl">
License
</Link>
</NextLink>
<Link <Link
href="https://github.com/lucide-icons/lucide" href="https://github.com/lucide-icons/lucide"
isExternal isExternal
@@ -57,9 +46,20 @@ const MobileMenu = ({ children }: { children?: ReactNode }): JSX.Element => {
fontWeight="bold" fontWeight="bold"
display="block" display="block"
mb={2} mb={2}
key={menuItem.name}
> >
Github {menuItem.name}
</Link> </Link>
);
}
return (
<NextLink href={menuItem.href} passHref>
<Link fontSize="lg" fontWeight="bold" display="block" mb={2}>
{menuItem.name}
</Link>
</NextLink>
);
})}
</Box> </Box>
<Divider mt={2} /> <Divider mt={2} />
{children} {children}

View File

@@ -4,7 +4,7 @@ import {
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
useColorMode, useColorMode,
useUpdateEffect useUpdateEffect,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { Search as SearchIcon } from 'lucide-react'; import { Search as SearchIcon } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
@@ -17,8 +17,7 @@ interface SearchInputProps {
count: number; count: number;
} }
export const SearchInput = ( export const SearchInput = ({ onChange, count }: SearchInputProps) => {
({ onChange, count }: SearchInputProps) => {
const { colorMode } = useColorMode(); const { colorMode } = useColorMode();
const [urlValue, setUrlValue] = useRouterParam('search'); const [urlValue, setUrlValue] = useRouterParam('search');
@@ -54,7 +53,7 @@ export const SearchInput = (
}, []); }, []);
return ( return (
<InputGroup position="sticky" top={4} zIndex={1}> <InputGroup>
<InputLeftElement <InputLeftElement
children={ children={
<Icon> <Icon>
@@ -65,11 +64,10 @@ export const SearchInput = (
<Input <Input
ref={ref} ref={ref}
placeholder={`Search ${count} icons (Press "/" to focus)`} placeholder={`Search ${count} icons (Press "/" to focus)`}
onChange={(event) => setInputValue(event.target.value)} onChange={event => setInputValue(event.target.value)}
value={inputValue} value={inputValue}
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]} bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
/> />
</InputGroup> </InputGroup>
); );
} };
);

View File

@@ -0,0 +1,97 @@
import { Box, Heading, useColorModeValue } from '@chakra-ui/react';
import { motion } from 'framer-motion';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Category, IconEntity } from 'src/types';
import theme from '../lib/theme';
import IconReorder from './IconReorder';
const UnCategorizedIcons = ({
icons,
dropZones,
dragging,
setDragging,
categories,
handleChange,
}): JSX.Element => {
const [scrollPosition, setScrollPosition] = useState(0);
const boxBackground = useColorModeValue(theme.colors.white, theme.colors.gray[700]);
const allIconContainerRef = useRef(null);
const unCategorizedIcons = useMemo(() => {
return (icons as IconEntity[]).filter(icon => {
return !Object.values(categories as Category[])
.flat()
.some(categorizedIcon => categorizedIcon.name === icon.name);
});
}, [icons, categories]);
const onItemDrop = useCallback(
(iconName: string, targetCategory: string) => {
const newIcons = [...categories[targetCategory].map(({ name }) => name), iconName];
handleChange(targetCategory)(newIcons);
},
[categories],
);
useEffect(() => {
allIconContainerRef.current.addEventListener('scroll', () => {
setScrollPosition(allIconContainerRef.current.scrollTop);
});
}, []);
return (
<Box paddingTop={0} position="sticky" top="0">
<Box
marginTop={5}
marginBottom={320}
maxWidth="calc(1600px / 2)"
width="100%"
height="calc(100vh)"
borderWidth="1px"
boxSizing="border-box"
rounded="lg"
boxShadow={theme.shadows.xl}
bg={boxBackground}
padding={8}
overflowY={dragging ? 'visible' : 'auto'}
ref={allIconContainerRef}
as={motion.div}
layoutScroll
>
<Box marginTop={dragging ? scrollPosition * -1 : undefined}>
<Heading as="h5" size="sm" marginBottom={4}>
Uncategorized Icons
</Heading>
<IconReorder
// key={`uncatogorized-${newKey}`}
icons={unCategorizedIcons}
dropZones={dropZones}
onDrop={onItemDrop}
// eslint-disable-next-line @typescript-eslint/no-empty-function
setIcons={() => {}}
dragging={dragging}
setDragging={setDragging}
/>
<Heading as="h5" size="sm" marginBottom={4}>
All Icons
</Heading>
<IconReorder
icons={Object.values(icons)}
dropZones={dropZones}
onDrop={onItemDrop}
// eslint-disable-next-line @typescript-eslint/no-empty-function
setIcons={() => {}}
dragging={dragging}
setDragging={setDragging}
sx={{
opacity: 0.4,
}}
/>
</Box>
</Box>
</Box>
);
};
export default UnCategorizedIcons;

View File

@@ -0,0 +1,28 @@
import { animate, MotionValue, useMotionValue } from 'framer-motion';
import { useEffect } from 'react';
const inactiveShadow = '0px 0px 0px rgba(0,0,0,0.8)';
export default function useRaisedShadow(value: MotionValue<number>): MotionValue<string> {
const boxShadow = useMotionValue(inactiveShadow);
useEffect(() => {
let isActive = false;
value.onChange(latest => {
const wasActive = isActive;
if (latest !== 0) {
isActive = true;
if (isActive !== wasActive) {
animate(boxShadow, '5px 5px 10px rgba(0,0,0,0.3)');
}
} else {
isActive = false;
if (isActive !== wasActive) {
animate(boxShadow, inactiveShadow);
}
}
});
}, [value, boxShadow]);
return boxShadow;
}

View File

@@ -0,0 +1,32 @@
import fs from "fs";
import path from "path";
import {Category, IconEntity} from "../types";
import {getAllData} from "./icons";
const directory = path.join(process.cwd(), "../categories");
export function getAllCategoryFiles() {
const fileNames = fs.readdirSync(directory).filter((file) => path.extname(file) === '.json');
return fileNames
.map((fileName) => path.basename(fileName, '.json'));
}
export async function getData(name: string, icons: IconEntity[]): Promise<Category> {
const jsonPath = path.join(directory, `${name}.json`);
const jsonContent = fs.readFileSync(jsonPath, "utf8");
const categoryJson = JSON.parse(jsonContent);
return {
...categoryJson,
name,
iconCount: icons.reduce((acc, curr) => (curr.categories.includes(name) ? ++acc : acc), 0)
};
}
export async function getAllCategories(): Promise<Category[]> {
const names = getAllCategoryFiles();
const icons = await getAllData();
return Promise.all(names.map((name) => getData(name, icons)));
}

View File

@@ -1,14 +1,22 @@
import NextCache from './nextCache'; import NextCache from './nextCache';
import {parseSync} from 'svgson'; import {getAllData, GetDataOptions} from './icons';
import {getAllData} from './icons';
export type IconNode = [string, object, IconNode[]]; export type IconNode = [string, object, IconNode[]];
export type IconNodes = {[iconName: string]: IconNode}; export type IconNodes = {[iconName: string]: IconNode};
export function fetchIconNodes(writeCache = true): Promise<IconNodes> { export function fetchIconNodes(writeCache = true, options?: GetDataOptions): Promise<IconNodes> {
return NextCache.resolve('api-icon-nodes', async () => { if (options?.withChildKeys) {
return (await getAllData()).reduce((acc, icon) => { return NextCache.resolve('api-icon-nodes-with-keys', async () => {
acc[icon.name] = parseSync(icon.src).children.map(({name, attributes}) => [name, attributes]); return (await getAllData({ withChildKeys : true})).reduce((acc, icon) => {
acc[icon.name] = icon.iconNode
return acc;
}, {});
}, writeCache);
}
return NextCache.resolve('api-icon-nodes', async () => {
return (await getAllData()).reduce((acc, icon) => {
acc[icon.name] = icon.iconNode
return acc; return acc;
}, {}); }, {});
}, writeCache); }, writeCache);

View File

@@ -0,0 +1,17 @@
export type IconContent = [icon: string, src:string];
async function generateZip(icons: IconContent[]) {
const JSZip = (await import('jszip')).default
const zip = new JSZip();
const addingZipPromises = icons.map(([name, src]) =>
zip.file(`${name}.svg`, src),
);
await Promise.all(addingZipPromises)
return zip.generateAsync({ type: 'blob' });
}
export default generateZip

View File

@@ -0,0 +1,16 @@
import { createLucideIcon, LucideProps } from "lucide-react"
import { IconEntity } from "src/types"
import { renderToStaticMarkup } from 'react-dom/server';
import { IconContent } from "./generateZip";
const getFallbackZip = (icons: IconEntity[], params: LucideProps) => {
return icons
.map<IconContent>((icon) => {
const Icon = createLucideIcon(icon.name, icon.iconNode)
const src = renderToStaticMarkup(<Icon {...params} />)
return [icon.name, src]
})
}
export default getFallbackZip

28
site/src/lib/helpers.ts Normal file
View File

@@ -0,0 +1,28 @@
/**
* djb2 hashing function
*
* @param {string} string
* @param {number} seed
* @returns {string} A hashed string of 6 characters
*/
export const hash = (string: string, seed = 5381) => {
let i = string.length;
while (i) {
// eslint-disable-next-line no-bitwise, no-plusplus
seed = (seed * 33) ^ string.charCodeAt(--i);
}
// eslint-disable-next-line no-bitwise
return (seed >>> 0).toString(36).substr(0, 6);
};
/**
* Generate Hashed string based on name and attributes
*
* @param {object} seed
* @param {string} seed.name A name, for example an icon name
* @param {object} seed.attributes An object of SVGElement Attrbutes
* @returns {string} A hashed string of 6 characters
*/
export const generateHashedKey = ({ name, attributes }) => hash(JSON.stringify([name, attributes]));

View File

@@ -1,7 +1,10 @@
import fs from "fs"; import fs from "fs";
import path from "path"; import path from "path";
import { parseSync } from "svgson";
import { IconNode } from "../../../packages/lucide-react/src/createLucideIcon";
import { IconEntity } from "../types"; import { IconEntity } from "../types";
import { getContributors } from "./fetchAllContributors"; import { getContributors } from "./fetchAllContributors";
import { generateHashedKey } from "./helpers";
const directory = path.join(process.cwd(), "../icons"); const directory = path.join(process.cwd(), "../icons");
@@ -13,25 +16,42 @@ export function getAllNames() {
.map((fileName) => path.basename(fileName, '.json')); .map((fileName) => path.basename(fileName, '.json'));
} }
export async function getData(name: string) { export interface GetDataOptions {
withChildKeys?: boolean
}
export async function getData(name: string, { withChildKeys = false }: GetDataOptions | undefined = {}) {
const svgPath = path.join(directory, `${name}.svg`); const svgPath = path.join(directory, `${name}.svg`);
const svgContent = fs.readFileSync(svgPath, "utf8"); const svgContent = fs.readFileSync(svgPath, "utf8");
const jsonPath = path.join(directory, `${name}.json`); const jsonPath = path.join(directory, `${name}.json`);
const jsonContent = fs.readFileSync(jsonPath, "utf8"); const jsonContent = fs.readFileSync(jsonPath, "utf8");
const iconJson = JSON.parse(jsonContent); const { tags, categories } = JSON.parse(jsonContent);
const iconNode = parseSync(svgContent).children.map(
(child) => {
const { name, attributes } = child
if (withChildKeys) {
attributes.key = generateHashedKey(child)
}
return [name, attributes]
}
) as IconNode
const contributors = await getContributors(name); const contributors = await getContributors(name);
return { return {
...iconJson,
name, name,
tags,
categories,
contributors, contributors,
src: svgContent iconNode,
}; };
} }
export async function getAllData(): Promise<IconEntity[]> { export async function getAllData(options?: GetDataOptions): Promise<IconEntity[]> {
const names = getAllNames(); const names = getAllNames();
return Promise.all(names.map((name) => getData(name))); return Promise.all(names.map((name) => getData(name, options)));
} }

View File

@@ -21,6 +21,19 @@ const theme = {
'800': '#A70B0B', '800': '#A70B0B',
'900': '#720707' '900': '#720707'
}, },
},
component: {
Button: {
variants: {
solid: (props) => {
// if(props?.colorScheme === 'red') {
return {
bg: props.colorMode === 'dark' ? 'red.700' : 'red.100',
}
// }
},
}
}
} }
}; };

View File

@@ -1,8 +1,16 @@
import { NextApiRequest, NextApiResponse } from 'next';
import {fetchIconNodes} from '../../../lib/fetchIconNodes'; import {fetchIconNodes} from '../../../lib/fetchIconNodes';
export default async function handler(req, res) { export default async function handler(request: NextApiRequest, response: NextApiResponse) {
res.setHeader( const params = request.query
return response
.setHeader(
'Cache-Control', 'Cache-Control',
'public, max-age=86400' 'public, max-age=86400'
).status(200).json(await fetchIconNodes(false)); )
.status(200)
.json(
await fetchIconNodes(false, params)
);
} }

View File

@@ -0,0 +1,105 @@
import { getAllData } from '../../lib/icons';
import Layout from '../../components/Layout';
import { Box, Grid, Heading } from '@chakra-ui/react';
import IconCategory from '../../components/IconCategory';
import CategoryChangesBar from '../../components/CategoryChangesBar';
import { useState, useRef, RefAttributes } from 'react';
import IconReorder from '../../components/IconReorder';
import UnCategorizedIcons from '../../components/UnCategorizedIcons';
import { getAllCategories } from 'src/lib/categories';
import { Category, IconEntity } from 'src/types';
interface EditCategoriesPageProps {
icons: IconEntity[]
categories: Category[]
}
const EditCategoriesPage = ({ icons = [], categories: categoryList }: EditCategoriesPageProps) => {
const [dragging, setDragging] = useState(false);
const [categories, setCategories] = useState<Record<string, IconEntity[]>>(
categoryList.reduce((categoryMap, { name }) => {
const categoryIcons = icons.filter(({categories}) => categories.includes(name))
categoryMap[name] = categoryIcons
return categoryMap;
}, {}),
);
const [changes, setChanges] = useState(0);
const dropZones = useRef<[string, HTMLDivElement][]>([]);
const handleChange = (category: string) => (newIcons: string[]) => {
console.log(category, newIcons);
setCategories(currentCategories => {
const newCategories = {
...currentCategories,
[category]: newIcons.map(iconName => icons[iconName]),
};
if (JSON.stringify(currentCategories) !== JSON.stringify(newCategories)) {
setChanges(changes => changes + 1);
}
return newCategories;
});
};
return (
<Layout maxWidth="1600px">
<CategoryChangesBar categories={categories} changes={changes} />
<Grid templateColumns="1fr 1fr" gridColumnGap={6}>
<Box>
<Heading as="h4" size="md" paddingTop={4}>
All Icons
</Heading>
<UnCategorizedIcons
icons={icons}
dropZones={dropZones}
dragging={dragging}
setDragging={setDragging}
categories={categories}
handleChange={handleChange}
/>
</Box>
<Box>
<Heading as="h4" size="md" paddingTop={4} paddingLeft={4}>
Categories
</Heading>
<Box marginTop={5} marginBottom={320} marginLeft="auto">
{Object.entries(categories).map(([category, icons], index) => (
<IconCategory
name={category}
key={category}
dragging={dragging}
ref={el =>
(dropZones.current[index] = [category, el]) as RefAttributes<HTMLDivElement>
}
>
<IconReorder
key={`${category}-reorder`}
icons={icons}
setIcons={handleChange(category)}
dropZones={dropZones}
// onDrop={onItemDrop}
dragging={dragging}
setDragging={setDragging}
/>
</IconCategory>
))}
</Box>
</Box>
</Grid>
</Layout>
);
};
export default EditCategoriesPage;
export async function getStaticProps() {
const icons = await getAllData({ withChildKeys: true });
const categories = await getAllCategories()
return { props: { icons, categories } };
}

View File

@@ -1,12 +1,13 @@
import { useMemo } from 'react';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import IconDetailOverlay from '../../components/IconDetailOverlay'; import IconDetailOverlay from '../../components/IconDetailOverlay';
import { getAllData, getData } from '../../lib/icons'; import { getAllData, getData } from '../../lib/icons';
import IconOverview from '../../components/IconOverview'; import IconOverview from '../../components/IconOverview';
import Layout from '../../components/Layout'; import Layout from '../../components/Layout';
import Header from '../../components/Header'; import { GetStaticPaths, GetStaticProps } from 'next';
import { useMemo } from 'react'; import { getAllCategories } from 'src/lib/categories';
const IconPage = ({ icon, data }) => { const IconPage = ({ icon, data, categories }): JSX.Element => {
const router = useRouter(); const router = useRouter();
const getIcon = iconName => data.find(({ name }) => name === iconName) || {}; const getIcon = iconName => data.find(({ name }) => name === iconName) || {};
@@ -21,11 +22,11 @@ const IconPage = ({ icon, data }) => {
router.push( router.push(
{ {
pathname: '/', pathname: '/icons',
query, query,
}, },
undefined, undefined,
{ scroll: false }, { scroll: false, shallow: true },
); );
}; };
@@ -39,21 +40,22 @@ const IconPage = ({ icon, data }) => {
return ( return (
<Layout> <Layout>
<IconDetailOverlay key={currentIcon.name} icon={currentIcon} close={onClose} open /> <IconDetailOverlay key={currentIcon.name} icon={currentIcon} close={onClose} open />
<Header {...{ data }} /> <IconOverview {...{ data, categories }} key="icon-overview" />
<IconOverview {...{ data }} />
</Layout> </Layout>
); );
}; };
export default IconPage; export default IconPage;
export async function getStaticProps({ params: { iconName } }) { export const getStaticProps: GetStaticProps = async ({ params: { iconName } }) => {
const data = await getAllData(); const data = await getAllData({ withChildKeys: true });
const icon = await getData(iconName); const icon = await getData(iconName as string, { withChildKeys: true });
return { props: { icon, data } }; const categories = await getAllCategories()
}
export async function getStaticPaths() { return { props: { icon, data, categories } };
};
export const getStaticPaths: GetStaticPaths = async () => {
const data = await getAllData(); const data = await getAllData();
return { return {
@@ -62,4 +64,4 @@ export async function getStaticPaths() {
})), })),
fallback: false, fallback: false,
}; };
} };

View File

@@ -0,0 +1,28 @@
import Layout from '../../components/Layout';
import IconOverview from '../../components/IconOverview';
import { getAllData } from '../../lib/icons';
import { getAllCategories } from 'src/lib/categories';
import IconDetailOverlay from 'src/components/IconDetailOverlay';
const IconsPage = ({ data, categories }) => {
return (
<Layout>
<IconDetailOverlay />
<IconOverview {...{ data, categories }} key="icon-overview" />
</Layout>
);
};
export default IconsPage;
export async function getStaticProps() {
const data = await getAllData({ withChildKeys: true });
const categories = await getAllCategories()
return {
props: {
data,
categories,
},
};
}

View File

@@ -8,13 +8,15 @@ import Header from '../components/Header';
import MobileMenu from '../components/MobileMenu'; import MobileMenu from '../components/MobileMenu';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { GetStaticPropsResult, NextPage } from 'next'; import { GetStaticPropsResult, NextPage } from 'next';
import { IconEntity } from '../types'; import { IconEntity, Category } from '../types';
import { getAllCategories } from 'src/lib/categories';
interface HomePageProps { interface HomePageProps {
data: IconEntity[] data: IconEntity[]
categories: Category[]
} }
const HomePage: NextPage<HomePageProps> = ({ data }) => { const HomePage: NextPage<HomePageProps> = ({ data, categories }) => {
const router = useRouter(); const router = useRouter();
const getIcon = iconName => data.find(({ name }) => name === iconName); const getIcon = iconName => data.find(({ name }) => name === iconName);
@@ -28,20 +30,28 @@ const HomePage: NextPage<HomePageProps> = ({ data }) => {
<IconDetailOverlay <IconDetailOverlay
open={!!currentIcon?.name} open={!!currentIcon?.name}
icon={currentIcon} icon={currentIcon}
close={() => router.push('/', undefined, { shallow: true })} close={() => router.push({
pathname: '/icon/[iconName]',
query: {
...router.query,
iconName: '',
},
}, undefined, { shallow: true })}
/> />
<Header {...{ data }} /> <Header {...{ data }} />
<IconOverview {...{ data }} /> <IconOverview {...{ data, categories }} />
</Layout> </Layout>
); );
}; };
export async function getStaticProps(): Promise<GetStaticPropsResult<HomePageProps>> { export async function getStaticProps(): Promise<GetStaticPropsResult<HomePageProps>> {
const data = await getAllData(); const data = await getAllData({ withChildKeys: true });
const categories = await getAllCategories()
return { return {
props: { props: {
data, data,
categories,
}, },
}; };
} }

View File

@@ -48,7 +48,12 @@ export const getStaticProps: GetStaticProps = async () => {
const licenseText = doc const licenseText = doc
.split(/\n{2,}/) .split(/\n{2,}/)
.map(paragraph => paragraph.split('\n').join(' ').trim()) .map(paragraph =>
paragraph
.split('\n')
.join(' ')
.trim(),
)
.filter(Boolean); .filter(Boolean);
return { props: { licenseText } }; return { props: { licenseText } };

View File

@@ -0,0 +1,23 @@
export default [
{
name: 'Icons',
href: '/icons',
},
{
name: 'Documentation',
href: '/docs',
},
{
name: 'Packages',
href: '/packages',
},
{
name: 'License',
href: '/license',
},
{
name: 'Github',
isExternal: true,
href: 'https://github.com/lucide-icons/lucide',
},
];

View File

@@ -1,10 +1,20 @@
import { IconNode } from "../../packages/lucide-react/dist/lucide-react";
export interface IconEntity { export interface IconEntity {
contributors: Contributor[];
name: string; name: string;
src: string;
tags: string[]; tags: string[];
categories: string[];
contributors: Contributor[];
iconNode: IconNode;
} }
export interface Contributor { export interface Contributor {
author: string; author: string;
} }
export interface Category {
name: string
title: string
icon?: string
iconCount: number
}

File diff suppressed because one or more lines are too long