Merge pull request #192 from y1hao/bookmark

Implement bookmarking
This commit is contained in:
Ibrahima G. Coulibaly
2025-07-15 14:16:26 +01:00
committed by GitHub
5 changed files with 207 additions and 68 deletions

View File

@@ -15,11 +15,16 @@ import { useState } from 'react';
import { DefinedTool } from '@tools/defineTool'; import { DefinedTool } from '@tools/defineTool';
import { filterTools, tools } from '@tools/index'; import { filterTools, tools } from '@tools/index';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import _ from 'lodash';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { getToolCategoryTitle } from '@utils/string'; import { getToolCategoryTitle } from '@utils/string';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { validNamespaces } from '../i18n'; import { validNamespaces } from '../i18n';
import {
getBookmarkedToolPaths,
isBookmarked,
toggleBookmarked
} from '@utils/bookmark';
import IconButton from '@mui/material/IconButton';
const GroupHeader = styled('div')(({ theme }) => ({ const GroupHeader = styled('div')(({ theme }) => ({
position: 'sticky', position: 'sticky',
@@ -36,61 +41,59 @@ const GroupItems = styled('ul')({
padding: 0 padding: 0
}); });
type ToolInfo = {
label: string;
url: string;
};
export default function Hero() { export default function Hero() {
const { t } = useTranslation(validNamespaces); const { t } = useTranslation(validNamespaces);
const [inputValue, setInputValue] = useState<string>(''); const [inputValue, setInputValue] = useState<string>('');
const theme = useTheme(); const theme = useTheme();
const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(tools); const [filteredTools, setFilteredTools] = useState<DefinedTool[]>(tools);
const [bookmarkedToolPaths, setBookmarkedToolPaths] = useState<string[]>(
getBookmarkedToolPaths()
);
const navigate = useNavigate(); const navigate = useNavigate();
const exampleTools: { label: string; url: string; translationKey: string }[] = const exampleTools: ToolInfo[] = [
[ {
{ label: t('translation:hero.examples.createTransparentImage'),
label: t('translation:hero.examples.createTransparentImage'), url: '/image-generic/create-transparent'
url: '/image-generic/create-transparent', },
translationKey: 'translation:hero.examples.createTransparentImage' {
}, label: t('translation:hero.examples.prettifyJson'),
{ url: '/json/prettify'
label: t('translation:hero.examples.prettifyJson'), },
url: '/json/prettify', {
translationKey: 'translation:hero.examples.prettifyJson' label: t('translation:hero.examples.changeGifSpeed'),
}, url: '/gif/change-speed'
{ },
label: t('translation:hero.examples.changeGifSpeed'), {
url: '/gif/change-speed', label: t('translation:hero.examples.sortList'),
translationKey: 'translation:hero.examples.changeGifSpeed' url: '/list/sort'
}, },
{ {
label: t('translation:hero.examples.sortList'), label: t('translation:hero.examples.compressPng'),
url: '/list/sort', url: '/png/compress-png'
translationKey: 'translation:hero.examples.sortList' },
}, {
{ label: t('translation:hero.examples.splitText'),
label: t('translation:hero.examples.compressPng'), url: '/string/split'
url: '/png/compress-png', },
translationKey: 'translation:hero.examples.compressPng' {
}, label: t('translation:hero.examples.splitPdf'),
{ url: '/pdf/split-pdf'
label: t('translation:hero.examples.splitText'), },
url: '/string/split', {
translationKey: 'translation:hero.examples.splitText' label: t('translation:hero.examples.trimVideo'),
}, url: '/video/trim'
{ },
label: t('translation:hero.examples.splitPdf'), {
url: '/pdf/split-pdf', label: t('translation:hero.examples.calculateNumberSum'),
translationKey: 'translation:hero.examples.splitPdf' url: '/number/sum'
}, }
{ ];
label: t('translation:hero.examples.trimVideo'),
url: '/video/trim',
translationKey: 'translation:hero.examples.trimVideo'
},
{
label: t('translation:hero.examples.calculateNumberSum'),
url: '/number/sum',
translationKey: 'translation:hero.examples.calculateNumberSum'
}
];
const handleInputChange = ( const handleInputChange = (
event: React.ChangeEvent<{}>, event: React.ChangeEvent<{}>,
@@ -99,6 +102,24 @@ export default function Hero() {
setInputValue(newInputValue); setInputValue(newInputValue);
setFilteredTools(filterTools(tools, newInputValue, t)); setFilteredTools(filterTools(tools, newInputValue, t));
}; };
const toolsMap = new Map<string, ToolInfo>();
for (const tool of filteredTools) {
toolsMap.set(tool.path, {
label: tool.name,
url: '/' + tool.path
});
}
const displayedTools =
bookmarkedToolPaths.length > 0
? bookmarkedToolPaths.flatMap((path) => {
const tool = toolsMap.get(path);
if (tool === undefined) {
return [];
}
return [{ ...tool, label: t(tool.label) }];
})
: exampleTools;
return ( return (
<Box width={{ xs: '90%', md: '80%', lg: '60%' }}> <Box width={{ xs: '90%', md: '80%', lg: '60%' }}>
@@ -159,14 +180,42 @@ export default function Hero() {
{...props} {...props}
onClick={() => navigate('/' + option.path)} onClick={() => navigate('/' + option.path)}
> >
<Stack direction={'row'} spacing={2} alignItems={'center'}> <Stack
<Icon fontSize={20} icon={option.icon} /> direction={'row'}
<Box> alignItems={'center'}
<Typography fontWeight={'bold'}>{t(option.name)}</Typography> justifyContent={'space-between'}
<Typography fontSize={12}> width={'100%'}
{t(option.shortDescription)} >
</Typography> <Stack direction={'row'} spacing={2} alignItems={'center'}>
</Box> <Icon fontSize={20} icon={option.icon} />
<Box>
<Typography fontWeight={'bold'}>{t(option.name)}</Typography>
<Typography fontSize={12}>
{t(option.shortDescription)}
</Typography>
</Box>
</Stack>
<IconButton
onClick={(e) => {
e.stopPropagation();
toggleBookmarked(option.path);
setBookmarkedToolPaths(getBookmarkedToolPaths());
}}
>
<Icon
fontSize={20}
color={
isBookmarked(option.path)
? theme.palette.primary.main
: theme.palette.grey[500]
}
icon={
isBookmarked(option.path)
? 'mdi:bookmark'
: 'mdi:bookmark-plus-outline'
}
/>
</IconButton>
</Stack> </Stack>
</Box> </Box>
)} )}
@@ -177,7 +226,7 @@ export default function Hero() {
}} }}
/> />
<Grid container spacing={2} mt={2}> <Grid container spacing={2} mt={2}>
{exampleTools.map((tool) => ( {displayedTools.map((tool) => (
<Grid <Grid
onClick={() => onClick={() =>
navigate(tool.url.startsWith('/') ? tool.url : `/${tool.url}`) navigate(tool.url.startsWith('/') ? tool.url : `/${tool.url}`)
@@ -186,7 +235,7 @@ export default function Hero() {
xs={12} xs={12}
md={6} md={6}
lg={4} lg={4}
key={tool.translationKey} key={tool.label}
> >
<Box <Box
sx={{ sx={{
@@ -202,10 +251,30 @@ export default function Hero() {
cursor: 'pointer', cursor: 'pointer',
'&:hover': { '&:hover': {
backgroundColor: 'background.hover' backgroundColor: 'background.hover'
} },
height: '100%'
}} }}
> >
<Typography>{tool.label}</Typography> <Stack direction={'row'} spacing={1} alignItems={'center'}>
<Typography textAlign={'center'}>{tool.label}</Typography>
{bookmarkedToolPaths.length > 0 && (
<IconButton
onClick={(e) => {
e.stopPropagation();
const path = tool.url.substring(1);
toggleBookmarked(path);
setBookmarkedToolPaths(getBookmarkedToolPaths());
}}
size={'small'}
>
<Icon
icon={'mdi:close'}
color={theme.palette.grey[500]}
fontSize={15}
/>
</IconButton>
)}
</Stack>
</Box> </Box>
</Grid> </Grid>
))} ))}

View File

@@ -1,4 +1,4 @@
import { Box, Button, styled, useTheme } from '@mui/material'; import { Box, Button, Stack, styled, useTheme } from '@mui/material';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import ToolBreadcrumb from './ToolBreadcrumb'; import ToolBreadcrumb from './ToolBreadcrumb';
import { capitalizeFirstLetter } from '../utils/string'; import { capitalizeFirstLetter } from '../utils/string';
@@ -7,6 +7,8 @@ import { Icon, IconifyIcon } from '@iconify/react';
import { categoriesColors } from '../config/uiConfig'; import { categoriesColors } from '../config/uiConfig';
import { getToolsByCategory } from '@tools/index'; import { getToolsByCategory } from '@tools/index';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { isBookmarked, toggleBookmarked } from '@utils/bookmark';
import IconButton from '@mui/material/IconButton';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const StyledButton = styled(Button)(({ theme }) => ({ const StyledButton = styled(Button)(({ theme }) => ({
@@ -22,6 +24,7 @@ interface ToolHeaderProps {
description: string; description: string;
icon?: IconifyIcon | string; icon?: IconifyIcon | string;
type: string; type: string;
path: string;
} }
function ToolLinks() { function ToolLinks() {
@@ -82,8 +85,11 @@ export default function ToolHeader({
icon, icon,
title, title,
description, description,
type type,
path
}: ToolHeaderProps) { }: ToolHeaderProps) {
const theme = useTheme();
const [bookmarked, setBookmarked] = useState<boolean>(isBookmarked(path));
return ( return (
<Box my={4}> <Box my={4}>
<ToolBreadcrumb <ToolBreadcrumb
@@ -100,9 +106,27 @@ export default function ToolHeader({
/> />
<Grid mt={1} container spacing={2}> <Grid mt={1} container spacing={2}>
<Grid item xs={12} md={8}> <Grid item xs={12} md={8}>
<Typography mb={2} fontSize={30} color={'primary'}> <Stack direction={'row'} spacing={2} alignItems={'center'}>
{title} <Typography mb={2} fontSize={30} color={'primary'}>
</Typography> {title}
</Typography>
<IconButton
onClick={(e) => {
toggleBookmarked(path);
setBookmarked(!bookmarked);
}}
>
<Icon
fontSize={30}
color={
bookmarked
? theme.palette.primary.main
: theme.palette.grey[500]
}
icon={bookmarked ? 'mdi:bookmark' : 'mdi:bookmark-plus-outline'}
/>
</IconButton>
</Stack>
<Typography fontSize={20}>{description}</Typography> <Typography fontSize={20}>{description}</Typography>
<ToolLinks /> <ToolLinks />
</Grid> </Grid>

View File

@@ -17,11 +17,13 @@ import { FullI18nKey } from '../i18n';
export default function ToolLayout({ export default function ToolLayout({
children, children,
icon, icon,
i18n,
type, type,
i18n fullPath
}: { }: {
icon?: IconifyIcon | string; icon?: IconifyIcon | string;
type: ToolCategory; type: ToolCategory;
fullPath: string;
children: ReactNode; children: ReactNode;
i18n?: { i18n?: {
name: FullI18nKey; name: FullI18nKey;
@@ -68,6 +70,7 @@ export default function ToolLayout({
description={toolDescription} description={toolDescription}
icon={icon} icon={icon}
type={type} type={type}
path={fullPath}
/> />
{children} {children}
<Separator backgroundColor="#5581b5" margin="50px" /> <Separator backgroundColor="#5581b5" margin="50px" />

View File

@@ -65,7 +65,12 @@ export const defineTool = (
component: function ToolComponent() { component: function ToolComponent() {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<ToolLayout icon={icon} type={basePath} i18n={i18n}> <ToolLayout
icon={icon}
type={basePath}
i18n={i18n}
fullPath={`${basePath}/${path}`}
>
<Component <Component
title={t(i18n.name)} title={t(i18n.name)}
longDescription={ longDescription={

38
src/utils/bookmark.ts Normal file
View File

@@ -0,0 +1,38 @@
const bookmarkedToolsKey = 'bookmarkedTools';
export function getBookmarkedToolPaths(): string[] {
return (
localStorage
.getItem(bookmarkedToolsKey)
?.split(',')
?.filter((path) => path) ?? []
);
}
export function isBookmarked(toolPath: string): boolean {
return getBookmarkedToolPaths().some((path) => path === toolPath);
}
export function toggleBookmarked(toolPath: string) {
if (isBookmarked(toolPath)) {
unbookmark(toolPath);
} else {
bookmark(toolPath);
}
}
function bookmark(toolPath: string) {
localStorage.setItem(
bookmarkedToolsKey,
[toolPath, ...getBookmarkedToolPaths()].join(',')
);
}
function unbookmark(toolPath: string) {
localStorage.setItem(
bookmarkedToolsKey,
getBookmarkedToolPaths()
.filter((path) => path !== toolPath)
.join(',')
);
}