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

@@ -2,4 +2,4 @@
"$schema": "../category.schema.json",
"title": "Text formatting",
"icon": "type"
}
}

View File

@@ -1,7 +1,7 @@
import { forwardRef, createElement, ReactSVG, SVGProps } from 'react';
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>>

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',
'plugin:@typescript-eslint/recommended',
'plugin:@next/next/recommended',
'plugin:react-hooks/recommended'
],
parserOptions: {
tsconfigRootDir: __dirname,

View File

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

View File

@@ -17,3 +17,7 @@ body {
min-height: 100%;
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 nightOwlDarkTheme from 'prism-react-renderer/themes/nightOwl';
import uiTheme from '../lib/theme';
// import theme from 'prism-react-renderer/themes/nightOwl';
import BaseHighlight, { defaultProps, Language } from 'prism-react-renderer';
import { CSSProperties } from 'react';
import CopyButton from './CopyButton';
@@ -49,7 +48,7 @@ function CodeBlock({ code, language, metastring, showLines, ...props }: Highligh
const { colorMode } = useColorMode();
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 customizedCodeTheme = {
@@ -63,6 +62,8 @@ function CodeBlock({ code, language, metastring, showLines, ...props }: Highligh
return (
<Box position="relative" zIndex="0" {...props}>
<CodeContainer bg={backgroundColor}>
{/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<BaseHighlight
{...defaultProps}
code={code}

View File

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

View File

@@ -59,10 +59,21 @@ export function CustomizeIconContext({ children }): JSX.Element {
return <IconStyleContext.Provider value={value}>{children}</IconStyleContext.Provider>;
}
export function useCustomizeIconContext(): ICustomIconStyle {
export function useCustomizeIconContext() {
const context = useContext(IconStyleContext);
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;
}

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, Github } from 'lucide-react';
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 FlutterLogo from '../../public/framework-logos/flutter.svg';
import SvelteLogo from '../../public/framework-logos/svelte.svg';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { useCustomizeIconContext } from './CustomizeIconContext';
import { IconEntity } from '../types';
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' });
}
import generateZip, { IconContent } from 'src/lib/generateZip';
interface HeaderProps {
data: IconEntity[];
@@ -37,25 +22,27 @@ interface HeaderProps {
const Header = ({ data }: HeaderProps) => {
const [zippingIcons, setZippingIcons] = useState(false);
const { iconsRef } = useCustomizeIconContext();
const { iconsRef, strokeWidth, color, size } = useCustomizeIconContext();
const downloadAllIcons = async () => {
const downloadAllIcons = useCallback(async () => {
setZippingIcons(true);
let iconEntries: IconContent[] = Object.entries(iconsRef.current).map(([name, svgEl]) => [
let iconEntries: IconContent[] = Object.entries(iconsRef.current)
.map(([name, svgEl]) => [
name,
svgEl.outerHTML,
]);
// Fallback
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);
download(zip, 'lucide.zip');
setZippingIcons(false);
};
}, []);
const repositoryUrl = 'https://github.com/lucide-icons/lucide';
@@ -117,83 +104,85 @@ const Header = ({ data }: HeaderProps) => {
];
return (
<Flex direction="column" align="center" justify="center">
<Heading as="h1" fontSize="4xl" mb="4" textAlign="center">
Beautiful &amp; consistent icon toolkit made by the community.
</Heading>
<Text fontSize="lg" as="p" textAlign="center" mb="1">
Open-source project and a fork of{' '}
<Link href="https://github.com/feathericons/feather" isExternal>
Feather Icons
</Link>
. <br />
We're expanding the icon set as much as possible while keeping it nice-looking -{' '}
<Link href={repositoryUrl} isExternal>
join us
</Link>
!
</Text>
<Wrap
marginTop={4}
marginBottom={6}
spacing={{ base: 4, lg: 6 }}
justify="center"
align="center"
>
<WrapItem flexBasis="100%" style={{ marginBottom: 0 }}>
<NextLink href="/packages" passHref>
<Link _hover={{ opacity: 0.8 }} marginX="auto">
<Text fontSize="md" opacity={0.5} as="p" textAlign="center" width="100%">
Available for:
</Text>
</Link>
</NextLink>
</WrapItem>
{packages.map(({ name, href, Logo, label }) => (
<WrapItem key={name}>
<NextLink href={href} key={name} passHref>
<Link _hover={{ opacity: 0.8 }} aria-label={label} title={label}>
<Logo />
<Box maxW="1250px" mx="auto">
<Flex direction="column" align="center" justify="center" py={12}>
<Heading as="h1" fontSize="4xl" mb="4" textAlign="center">
Beautiful &amp; consistent icon toolkit made by the community.
</Heading>
<Text fontSize="lg" as="p" textAlign="center" mb="1">
Open-source project and a fork of{' '}
<Link href="https://github.com/feathericons/feather" isExternal>
Feather Icons
</Link>
. <br />
We're expanding the icon set as much as possible while keeping it nice-looking -{' '}
<Link href={repositoryUrl} isExternal>
join us
</Link>
!
</Text>
<Wrap
marginTop={4}
marginBottom={6}
spacing={{ base: 4, lg: 6 }}
justify="center"
align="center"
>
<WrapItem flexBasis="100%" style={{ marginBottom: 0 }}>
<NextLink href="/packages" passHref>
<Link _hover={{ opacity: 0.8 }} marginX="auto">
<Text fontSize="md" opacity={0.5} as="p" textAlign="center" width="100%">
Available for:
</Text>
</Link>
</NextLink>
</WrapItem>
))}
<WrapItem>
<NextLink href="/packages" passHref>
<Link _hover={{ opacity: 0.8 }} marginX="auto">
<Text fontSize="md" opacity={0.5}>More options</Text>
</Link>
</NextLink>
</WrapItem>
</Wrap>
<Wrap marginTop={3} marginBottom={12} spacing="15px" justify="center">
<WrapItem>
<Button
leftIcon={<Download />}
size="lg"
onClick={downloadAllIcons}
isLoading={zippingIcons}
loadingText="Creating zip.."
>
Download all
</Button>
</WrapItem>
<WrapItem>
<IconCustomizerDrawer />
</WrapItem>
<WrapItem>
<Button
as="a"
leftIcon={<Github />}
size="lg"
href={repositoryUrl}
target="__blank"
>
Github
</Button>
</WrapItem>
</Wrap>
</Flex>
{packages.map(({ name, href, Logo, label }) => (
<WrapItem key={name}>
<NextLink href={href} key={name} passHref>
<Link _hover={{ opacity: 0.8 }} aria-label={label}>
<Logo />
</Link>
</NextLink>
</WrapItem>
))}
<WrapItem>
<NextLink href="/packages" passHref>
<Link _hover={{ opacity: 0.8 }} marginX="auto">
<Text fontSize="md" opacity={0.5}>More options</Text>
</Link>
</NextLink>
</WrapItem>
</Wrap>
<Wrap marginTop={3} marginBottom={12} spacing="15px" justify="center">
<WrapItem>
<Button
leftIcon={<Download />}
size="lg"
onClick={downloadAllIcons}
isLoading={zippingIcons}
loadingText="Creating zip.."
>
Download all
</Button>
</WrapItem>
<WrapItem>
<IconCustomizerDrawer />
</WrapItem>
<WrapItem>
<Button
as="a"
leftIcon={<Github />}
size="lg"
href={repositoryUrl}
target="__blank"
>
Github
</Button>
</WrapItem>
</Wrap>
</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 { IconStyleContext } from './CustomizeIconContext';
import { useState } from 'react';
import { useCustomizeIconContext } from './CustomizeIconContext';
import { Edit } from 'lucide-react';
import {
Button,
@@ -8,30 +8,56 @@ import {
DrawerCloseButton,
DrawerContent,
DrawerHeader,
DrawerOverlay,
FormControl,
FormLabel,
Grid,
Hide,
IconButton,
Show,
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
Flex,
Text,
ButtonProps,
} from '@chakra-ui/react';
import ColorPicker from './ColorPicker';
export function IconCustomizerDrawer() {
export const IconCustomizerDrawer = (props: ButtonProps) => {
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 (
<>
<Button as="a" leftIcon={<Edit />} size="lg" onClick={() => setShowCustomize(true)}>
Customize
</Button>
<Drawer isOpen={showCustomize} placement="right" onClose={() => setShowCustomize(false)}>
<DrawerOverlay />
<Hide below='md'>
<Button
as="a"
leftIcon={<Edit />}
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>
<DrawerCloseButton />
<DrawerHeader>Customize Icons</DrawerHeader>
@@ -42,7 +68,7 @@ export function IconCustomizerDrawer() {
<ColorPicker
color={color}
value={color}
onChangeComplete={(col) => setColor(col.hex)}
onChangeComplete={col => setColor(col.hex)}
/>
</FormControl>
<FormControl>
@@ -93,4 +119,4 @@ export function IconCustomizerDrawer() {
</Drawer>
</>
);
}
};

View File

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

View File

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

View File

@@ -1,16 +1,38 @@
import { Box, Text } from '@chakra-ui/react';
import React, { useState } from 'react';
import { Box, Text, IconButton, HStack } from '@chakra-ui/react';
import React, { memo, useEffect, useState } from 'react';
import useSearch from '../lib/useSearch';
import IconList from './IconList';
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 {
data: IconEntity[];
categories: Category[]
}
const IconOverview = ({ data }: IconOverviewProps) => {
const IconOverview = ({ data, categories }: IconOverviewProps): JSX.Element => {
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, [
{ name: 'name', weight: 2 },
@@ -18,20 +40,49 @@ const IconOverview = ({ data }: IconOverviewProps) => {
]);
return (
<>
<SearchInput onChange={setQuery} count={data.length} />
<Box>
<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}>
{searchResults.length > 0 ? (
<IconList icons={searchResults} />
) : (
<Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word">
No results found for "{query}"
</Text>
)}
</Box>
</>
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 ? (
categoryView ? (
<IconCategoryList icons={searchResults} data={data} categories={categories} />
) : (
<IconList icons={searchResults} />
)
) : (
<Text fontSize="2xl" fontWeight="bold" textAlign="center" wordBreak="break-word">
No results found for "{query}"
</Text>
)}
</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 { useMobileNavigationContext, useMobileNavigationValue } from './MobileNavigationProvider';
import Logo from './Logo';
import menuItems from '../static/menuItems';
interface LayoutProps extends BoxProps {
aside?: BoxProps['children'];
@@ -37,12 +38,14 @@ const Layout = ({ aside, children }: LayoutProps) => {
};
function setQuery(query) {
router.push({
router.push(
{
pathname: '/',
query: { query: query },
},
undefined,
{ shallow: true })
},
undefined,
{ shallow: true },
);
}
useKeyBindings({
@@ -56,7 +59,7 @@ const Layout = ({ aside, children }: LayoutProps) => {
return (
<Box h="100vh">
<Flex mb={16} w="full">
<Flex w="full">
<Flex
alignItems="center"
justifyContent="space-between"
@@ -72,29 +75,28 @@ const Layout = ({ aside, children }: LayoutProps) => {
<Flex justifyContent="center" alignItems="center">
{showBaseNavigation ? (
<>
<NextLink href="/docs" passHref>
<Link marginRight={12} fontSize="xl">
Documentation
</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
href="https://github.com/lucide-icons/lucide"
isExternal
marginRight={6}
fontSize="xl"
>
Github
</Link>
{menuItems.map(menuItem => {
if (menuItem.isExternal) {
return (
<Link
href={menuItem.href}
isExternal
marginRight={6}
fontSize="lg"
key={menuItem.name}
>
{menuItem.name}
</Link>
);
}
return (
<NextLink href={menuItem.href} passHref key={menuItem.name}>
<Link marginRight={8} fontSize="lg">
{menuItem.name}
</Link>
</NextLink>
);
})}
</>
) : null}
<IconButton
@@ -115,8 +117,12 @@ const Layout = ({ aside, children }: LayoutProps) => {
</Flex>
</Flex>
<Flex>
{aside ? <Box as="aside" marginRight={{ base: 0, lg: -240, }}>{aside}</Box> : null}
<Flex margin="0 auto" direction="column" maxW="1250px" px={5} width="100%">
{aside ? (
<Box as="aside" marginRight={{ base: 0, lg: -240 }}>
{aside}
</Box>
) : null}
<Flex margin="0 auto" direction="column" width="100%">
{children}
<Divider mb={6} mt={12} />
<p style={{ alignSelf: 'center' }}>

View File

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

View File

@@ -4,7 +4,7 @@ import {
InputGroup,
InputLeftElement,
useColorMode,
useUpdateEffect
useUpdateEffect,
} from '@chakra-ui/react';
import { Search as SearchIcon } from 'lucide-react';
import React, { useEffect, useRef, useState } from 'react';
@@ -17,59 +17,57 @@ interface SearchInputProps {
count: number;
}
export const SearchInput = (
({ onChange, count }: SearchInputProps) => {
const { colorMode } = useColorMode();
export const SearchInput = ({ onChange, count }: SearchInputProps) => {
const { colorMode } = useColorMode();
const [urlValue, setUrlValue] = useRouterParam('search');
const [urlValue, setUrlValue] = useRouterParam('search');
const [inputValue, setInputValue] = useState('');
const debouncedValue = useDebounce(inputValue.trim(), 300);
const [inputValue, setInputValue] = useState('');
const debouncedValue = useDebounce(inputValue.trim(), 300);
useUpdateEffect(() => {
onChange(debouncedValue);
setUrlValue(debouncedValue);
}, [debouncedValue]);
useUpdateEffect(() => {
onChange(debouncedValue);
setUrlValue(debouncedValue);
}, [debouncedValue]);
useEffect(() => {
if (urlValue && !inputValue) {
setInputValue(urlValue);
onChange(urlValue);
useEffect(() => {
if (urlValue && !inputValue) {
setInputValue(urlValue);
onChange(urlValue);
}
}, [urlValue]);
const ref = useRef(null);
// Keyboard `/` shortcut
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '/' && ref.current !== document.activeElement) {
event.preventDefault();
ref.current.focus();
}
}, [urlValue]);
const ref = useRef(null);
// Keyboard `/` shortcut
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === '/' && ref.current !== document.activeElement) {
event.preventDefault();
ref.current.focus();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
};
return (
<InputGroup position="sticky" top={4} zIndex={1}>
<InputLeftElement
children={
<Icon>
<SearchIcon />
</Icon>
}
/>
<Input
ref={ref}
placeholder={`Search ${count} icons (Press "/" to focus)`}
onChange={(event) => setInputValue(event.target.value)}
value={inputValue}
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
/>
</InputGroup>
);
}
);
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<InputGroup>
<InputLeftElement
children={
<Icon>
<SearchIcon />
</Icon>
}
/>
<Input
ref={ref}
placeholder={`Search ${count} icons (Press "/" to focus)`}
onChange={event => setInputValue(event.target.value)}
value={inputValue}
bg={colorMode == 'light' ? theme.colors.white : theme.colors.gray[700]}
/>
</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 {parseSync} from 'svgson';
import {getAllData} from './icons';
import {getAllData, GetDataOptions} from './icons';
export type IconNode = [string, object, IconNode[]];
export type IconNodes = {[iconName: string]: IconNode};
export function fetchIconNodes(writeCache = true): Promise<IconNodes> {
export function fetchIconNodes(writeCache = true, options?: GetDataOptions): Promise<IconNodes> {
if (options?.withChildKeys) {
return NextCache.resolve('api-icon-nodes-with-keys', async () => {
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] = parseSync(icon.src).children.map(({name, attributes}) => [name, attributes]);
acc[icon.name] = icon.iconNode
return acc;
}, {});
}, 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 path from "path";
import { parseSync } from "svgson";
import { IconNode } from "../../../packages/lucide-react/src/createLucideIcon";
import { IconEntity } from "../types";
import { getContributors } from "./fetchAllContributors";
import { generateHashedKey } from "./helpers";
const directory = path.join(process.cwd(), "../icons");
@@ -13,25 +16,42 @@ export function getAllNames() {
.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 svgContent = fs.readFileSync(svgPath, "utf8");
const jsonPath = path.join(directory, `${name}.json`);
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);
return {
...iconJson,
name,
tags,
categories,
contributors,
src: svgContent
iconNode,
};
}
export async function getAllData(): Promise<IconEntity[]> {
export async function getAllData(options?: GetDataOptions): Promise<IconEntity[]> {
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',
'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';
export default async function handler(req, res) {
res.setHeader(
'Cache-Control',
'public, max-age=86400'
).status(200).json(await fetchIconNodes(false));
export default async function handler(request: NextApiRequest, response: NextApiResponse) {
const params = request.query
return response
.setHeader(
'Cache-Control',
'public, max-age=86400'
)
.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 IconDetailOverlay from '../../components/IconDetailOverlay';
import { getAllData, getData } from '../../lib/icons';
import IconOverview from '../../components/IconOverview';
import Layout from '../../components/Layout';
import Header from '../../components/Header';
import { useMemo } from 'react';
import { GetStaticPaths, GetStaticProps } from 'next';
import { getAllCategories } from 'src/lib/categories';
const IconPage = ({ icon, data }) => {
const IconPage = ({ icon, data, categories }): JSX.Element => {
const router = useRouter();
const getIcon = iconName => data.find(({ name }) => name === iconName) || {};
@@ -21,11 +22,11 @@ const IconPage = ({ icon, data }) => {
router.push(
{
pathname: '/',
pathname: '/icons',
query,
},
undefined,
{ scroll: false },
{ scroll: false, shallow: true },
);
};
@@ -39,21 +40,22 @@ const IconPage = ({ icon, data }) => {
return (
<Layout>
<IconDetailOverlay key={currentIcon.name} icon={currentIcon} close={onClose} open />
<Header {...{ data }} />
<IconOverview {...{ data }} />
<IconOverview {...{ data, categories }} key="icon-overview" />
</Layout>
);
};
export default IconPage;
export async function getStaticProps({ params: { iconName } }) {
const data = await getAllData();
const icon = await getData(iconName);
return { props: { icon, data } };
}
export const getStaticProps: GetStaticProps = async ({ params: { iconName } }) => {
const data = await getAllData({ withChildKeys: true });
const icon = await getData(iconName as string, { withChildKeys: true });
const categories = await getAllCategories()
export async function getStaticPaths() {
return { props: { icon, data, categories } };
};
export const getStaticPaths: GetStaticPaths = async () => {
const data = await getAllData();
return {
@@ -62,4 +64,4 @@ export async function getStaticPaths() {
})),
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 { useMemo } from 'react';
import { GetStaticPropsResult, NextPage } from 'next';
import { IconEntity } from '../types';
import { IconEntity, Category } from '../types';
import { getAllCategories } from 'src/lib/categories';
interface HomePageProps {
data: IconEntity[]
categories: Category[]
}
const HomePage: NextPage<HomePageProps> = ({ data }) => {
const HomePage: NextPage<HomePageProps> = ({ data, categories }) => {
const router = useRouter();
const getIcon = iconName => data.find(({ name }) => name === iconName);
@@ -28,20 +30,28 @@ const HomePage: NextPage<HomePageProps> = ({ data }) => {
<IconDetailOverlay
open={!!currentIcon?.name}
icon={currentIcon}
close={() => router.push('/', undefined, { shallow: true })}
close={() => router.push({
pathname: '/icon/[iconName]',
query: {
...router.query,
iconName: '',
},
}, undefined, { shallow: true })}
/>
<Header {...{ data }} />
<IconOverview {...{ data }} />
<IconOverview {...{ data, categories }} />
</Layout>
);
};
export async function getStaticProps(): Promise<GetStaticPropsResult<HomePageProps>> {
const data = await getAllData();
const data = await getAllData({ withChildKeys: true });
const categories = await getAllCategories()
return {
props: {
data,
categories,
},
};
}

View File

@@ -48,7 +48,12 @@ export const getStaticProps: GetStaticProps = async () => {
const licenseText = doc
.split(/\n{2,}/)
.map(paragraph => paragraph.split('\n').join(' ').trim())
.map(paragraph =>
paragraph
.split('\n')
.join(' ')
.trim(),
)
.filter(Boolean);
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 {
contributors: Contributor[];
name: string;
src: string;
tags: string[];
categories: string[];
contributors: Contributor[];
iconNode: IconNode;
}
export interface Contributor {
author: string;
}
export interface Category {
name: string
title: string
icon?: string
iconCount: number
}

File diff suppressed because one or more lines are too long