feat: create new dashboard (layout, navbar, list of links)

This commit is contained in:
Sonny
2024-11-02 02:51:48 +01:00
committed by Sonny
parent 907dda300e
commit 757788bf9b
12 changed files with 404 additions and 128 deletions

View File

@@ -26,7 +26,7 @@ export default class CollectionsController {
}
// TODO: Create DTOs
return inertia.render('dashboard', {
return inertia.render('mantine_dashboard', {
collections: collections.map((collection) => collection.serialize()),
activeCollection:
activeCollection?.serialize() || collections[0].serialize(),

View File

@@ -0,0 +1,26 @@
import { Anchor } from '@mantine/core';
import { AnchorHTMLAttributes, CSSProperties, ReactNode } from 'react';
export function ExternalLinkStyled({
children,
title,
...props
}: AnchorHTMLAttributes<HTMLAnchorElement> & {
children: ReactNode;
style?: CSSProperties;
title?: string;
className?: string;
}) {
return (
<Anchor<'a'>
component="a"
underline="never"
target="_blank"
rel="noreferrer"
title={title}
{...props}
>
{children}
</Anchor>
);
}

View File

@@ -0,0 +1,60 @@
import { Avatar, Group, Menu, Text, UnstyledButton } from '@mantine/core';
import { forwardRef } from 'react';
import { TbChevronRight } from 'react-icons/tb';
import useUser from '~/hooks/use_user';
interface UserButtonProps extends React.ComponentPropsWithoutRef<'button'> {
image: string;
name: string;
email: string;
icon?: React.ReactNode;
}
const UserButton = forwardRef<HTMLButtonElement, UserButtonProps>(
({ image, name, email, icon, ...others }: UserButtonProps, ref) => (
<UnstyledButton
ref={ref}
style={{
color: 'var(--mantine-color-text)',
borderRadius: 'var(--mantine-radius-sm)',
}}
{...others}
>
<Group>
<Avatar src={image} radius="xl" />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{name}
</Text>
<Text c="dimmed" size="xs">
{email}
</Text>
</div>
{icon || <TbChevronRight size="1rem" />}
</Group>
</UnstyledButton>
)
);
export function MantineUserCard() {
const { user, isAuthenticated } = useUser();
return (
isAuthenticated && (
<Menu withArrow>
<Menu.Target>
<UserButton
image={user.avatarUrl}
name={user.fullname}
email={user.email}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item>Logout</Menu.Item>
</Menu.Dropdown>
</Menu>
)
);
}

View File

@@ -0,0 +1,44 @@
.linkWrapper {
user-select: none;
cursor: pointer;
width: 100%;
background-color: light-dark(--mantine-color-gray-1, rgb(50, 58, 71));
padding: 0.75em 1em;
border-radius: var(--border-radius);
border: 1px solid transparent;
}
.linkWrapper:hover {
border: 1px solid var(--mantine-color-blue-4);
}
.linkHeader {
display: flex;
gap: 1em;
align-items: center;
}
.linkName {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.linkDescription {
margin-top: 0.5em;
font-size: 0.8em;
word-wrap: break-word;
}
.linkUrl {
width: 100%;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
font-size: 0.8em;
}
.linkUrlPathname {
opacity: 0;
}

View File

@@ -0,0 +1,26 @@
.favicon {
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.faviconLoader {
position: absolute;
top: 0;
left: 0;
background-color: var(--secondary-color);
}
.faviconLoader > * {
animation: rotate 1s both reverse infinite linear;
}
@keyframes rotate {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@@ -1,8 +1,7 @@
import styled from '@emotion/styled';
import { Center, Loader } from '@mantine/core';
import { useEffect, useRef, useState } from 'react';
import { TbLoader3 } from 'react-icons/tb';
import { TfiWorld } from 'react-icons/tfi';
import { rotate } from '~/styles/keyframes';
import styles from './link_favicon.module.css';
const IMG_LOAD_TIMEOUT = 7_500;
@@ -11,27 +10,6 @@ interface LinkFaviconProps {
size?: number;
}
const Favicon = styled.div({
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
});
const FaviconLoader = styled.div(({ theme }) => ({
position: 'absolute',
top: 0,
left: 0,
color: theme.colors.font,
backgroundColor: theme.colors.secondary,
'& > *': {
animation: `${rotate} 1s both reverse infinite linear`,
},
}));
// The Favicon API should always return an image, so it's not really useful to keep the loader nor placeholder icon,
// but for slow connections and other random stuff, I'll keep this
export default function LinkFavicon({ url, size = 32 }: LinkFaviconProps) {
const imgRef = useRef<HTMLImageElement>(null);
@@ -47,7 +25,6 @@ export default function LinkFavicon({ url, size = 32 }: LinkFaviconProps) {
};
useEffect(() => {
// Ugly hack, onLoad cb not triggered on first load when SSR
if (imgRef.current?.complete) {
handleStopLoading();
return;
@@ -57,7 +34,7 @@ export default function LinkFavicon({ url, size = 32 }: LinkFaviconProps) {
}, [isLoading]);
return (
<Favicon>
<div className={styles.favicon}>
{!isFailed ? (
<img
src={`/favicon?url=${url}`}
@@ -73,10 +50,13 @@ export default function LinkFavicon({ url, size = 32 }: LinkFaviconProps) {
<TfiWorld size={size} />
)}
{isLoading && (
<FaviconLoader style={{ height: `${size}px`, width: `${size}px` }}>
<TbLoader3 size={size} />
</FaviconLoader>
<Center
className={styles.faviconLoader}
style={{ height: `${size}px`, width: `${size}px` }}
>
<Loader size="xs" />
</Center>
)}
</Favicon>
</div>
);
}

View File

@@ -1,76 +1,9 @@
import styled from '@emotion/styled';
import { Card, Group, Text } from '@mantine/core'; // Import de Mantine
import { AiFillStar } from 'react-icons/ai';
import ExternalLink from '~/components/common/external_link';
import LinkControls from '~/components/dashboard/link/link_controls';
import { ExternalLinkStyled } from '~/components/common/external_link_styled';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { Link } from '~/types/app';
const LinkWrapper = styled.li(({ theme }) => ({
userSelect: 'none',
cursor: 'pointer',
height: 'fit-content',
width: '100%',
color: theme.colors.primary,
backgroundColor: theme.colors.secondary,
padding: '0.75em 1em',
borderRadius: theme.border.radius,
'&:hover': {
outlineWidth: '1px',
outlineStyle: 'solid',
},
}));
const LinkHeader = styled.div(({ theme }) => ({
display: 'flex',
gap: '1em',
alignItems: 'center',
'& > a': {
height: '100%',
maxWidth: 'calc(100% - 75px)', // TODO: fix this, it is ugly af :(
textDecoration: 'none',
display: 'flex',
flex: 1,
flexDirection: 'column',
transition: theme.transition.delay,
'&, &:hover': {
border: 0,
},
},
}));
const LinkName = styled.div({
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
const LinkDescription = styled.div(({ theme }) => ({
marginTop: '0.5em',
color: theme.colors.font,
fontSize: '0.8em',
wordWrap: 'break-word',
}));
const LinkUrl = styled.span(({ theme }) => ({
width: '100%',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
color: theme.colors.grey,
fontSize: '0.8em',
}));
const StarIcon = styled(AiFillStar)(({ theme }) => ({
color: theme.colors.yellow,
}));
const LinkUrlPathname = styled.span({
opacity: 0,
});
import styles from './link.module.css';
export default function LinkItem({
link,
@@ -79,21 +12,34 @@ export default function LinkItem({
link: Link;
showUserControls: boolean;
}) {
const { id, name, url, description, favorite } = link;
const { name, url, description, favorite } = link;
return (
<LinkWrapper key={id} title={name}>
<LinkHeader>
<Card
className={styles.linkWrapper}
padding="xs"
pl="md"
pr="md"
radius="sm"
>
<Group className={styles.linkHeader} justify="space-between">
<LinkFavicon url={url} />
<ExternalLink href={url} className="reset">
<LinkName>
{name} {showUserControls && favorite && <StarIcon />}
</LinkName>
<ExternalLinkStyled href={url} style={{ flex: 1 }}>
<div className={styles.linkName}>
<Text c="blue" lineClamp={1}>
{name}{' '}
{showUserControls && favorite && <AiFillStar color="gold" />}
</Text>
</div>
<LinkItemURL url={url} />
</ExternalLink>
{showUserControls && <LinkControls link={link} />}
</LinkHeader>
{description && <LinkDescription>{description}</LinkDescription>}
</LinkWrapper>
</ExternalLinkStyled>
{/* {showUserControls && <LinkControls link={link} />} */}
</Group>
{description && (
<Text c="dimmed" size="sm">
{description}
</Text>
)}
</Card>
);
}
@@ -114,13 +60,17 @@ function LinkItemURL({ url }: { url: Link['url'] }) {
}
return (
<LinkUrl>
<Text className={styles.linkUrl} c="gray" size="xs" lineClamp={1}>
{origin}
<LinkUrlPathname>{text}</LinkUrlPathname>
</LinkUrl>
<span className={styles.linkUrlPathname}>{text}</span>
</Text>
);
} catch (error) {
console.error('error', error);
return <LinkUrl>{url}</LinkUrl>;
return (
<Text className={styles.linkUrl} c="gray" size="xs" lineClamp={1}>
{url}
</Text>
);
}
}

View File

@@ -0,0 +1,83 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import {
AppShell,
Burger,
Divider,
Group,
NavLink,
ScrollArea,
Skeleton,
Text,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { AiOutlineFolderAdd } from 'react-icons/ai';
import { IoIosSearch } from 'react-icons/io';
import { IoAdd, IoShieldHalfSharp } from 'react-icons/io5';
import { PiGearLight } from 'react-icons/pi';
import { MantineUserCard } from '~/components/common/mantine_user_card';
interface DashboardNavbarProps {
isOpen: boolean;
toggle: () => void;
}
export function DashboardNavbar({ isOpen, toggle }: DashboardNavbarProps) {
const { t } = useTranslation('common');
const common = {
variant: 'subtle',
color: 'blue',
active: true,
};
return (
<AppShell.Navbar p="md">
<Group hiddenFrom="sm" mb="md">
<Burger opened={isOpen} onClick={toggle} size="sm" />
<Text>Menu</Text>
</Group>
<MantineUserCard />
<Divider mt="xs" mb="md" />
<NavLink
{...common}
component={Link}
href={route('admin.dashboard').url}
label={t('admin')}
leftSection={<IoShieldHalfSharp size="1.5rem" />}
color="var(--mantine-color-red-5)"
/>
<NavLink
label={t('settings')}
leftSection={<PiGearLight size="1.5rem" />}
variant="subtle"
/>
<NavLink
{...common}
label={t('search')}
leftSection={<IoIosSearch size="1.5rem" />}
/>
<NavLink
{...common}
component={Link}
href={route('collection.create-form').url}
label={t('collection.create')}
leftSection={<IoAdd size="1.5rem" />}
/>
<NavLink
{...common}
component={Link}
href={route('link.create-form').url}
label={t('link.create')}
leftSection={<AiOutlineFolderAdd size="1.5rem" />}
/>
<Text size="sm" fw={500} c="dimmed" mt="sm" ml="md">
{t('favorite')} {0}
</Text>
<ScrollArea>
{Array(15)
.fill(0)
.map((_, index) => (
<Skeleton key={index} h={28} mt="sm" animate={false} />
))}
</ScrollArea>
</AppShell.Navbar>
);
}

View File

@@ -0,0 +1,3 @@
.ml_bg_color {
background-color: light-dark(var(--ml-bg-light), var(--ml-bg-dark));
}

View File

@@ -0,0 +1,100 @@
import {
AppShell,
Burger,
Group,
ScrollArea,
Stack,
Text,
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import DashboardProviders from '~/components/dashboard/dashboard_provider';
import LinkItem from '~/components/dashboard/link/link_item';
import { DashboardNavbar } from '~/mantine/components/dashboard/dashboard_navbar';
import { MantineDashboardLayout } from '~/mantine/layouts/mantine_dashboard_layout';
import { CollectionWithLinks } from '~/types/app';
import '../styles/body_overflow_hidden.css';
import classes from './dashboard.module.css';
interface DashboardPageProps {
collections: CollectionWithLinks[];
activeCollection: CollectionWithLinks;
}
export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
const [openedNavbar, { toggle: toggleNavbar }] = useDisclosure();
const [openedAside, { toggle: toggleAside }] = useDisclosure();
return (
<MantineDashboardLayout>
<DashboardProviders {...props}>
<AppShell
layout="alt"
header={{ height: 60 }}
footer={{ height: 60 }}
navbar={{
width: 300,
breakpoint: 'sm',
collapsed: { mobile: !openedNavbar },
}}
aside={{
width: 300,
breakpoint: 'md',
collapsed: { mobile: !openedAside },
}}
padding={0}
classNames={{
aside: classes.ml_bg_color,
footer: classes.ml_bg_color,
navbar: classes.ml_bg_color,
header: classes.ml_bg_color,
}}
>
<AppShell.Header style={{ display: 'flex', alignItems: 'center' }}>
<Group justify="space-between" px="md" flex={1}>
<Group h="100%">
<Burger
opened={openedNavbar}
onClick={toggleNavbar}
hiddenFrom="sm"
size="sm"
/>
Ma super collection
</Group>
<Burger
opened={openedAside}
onClick={toggleAside}
hiddenFrom="md"
size="md"
/>
</Group>
</AppShell.Header>
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
<AppShell.Main>
<ScrollArea
h="calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))"
p="md"
>
<Stack gap="xs">
{props.activeCollection.links.map((link) => (
<LinkItem key={link.id} link={link} showUserControls />
))}
</Stack>
</ScrollArea>
</AppShell.Main>
<AppShell.Aside p="md">
<Group justify="space-between">
<Text>Aside</Text>
<Burger
opened={openedAside}
onClick={toggleAside}
hiddenFrom="md"
size="md"
/>
</Group>
</AppShell.Aside>
<AppShell.Footer p="md">Footer</AppShell.Footer>
</AppShell>
</DashboardProviders>
</MantineDashboardLayout>
);
}

View File

@@ -0,0 +1,3 @@
body {
overflow: hidden;
}

View File

@@ -1,40 +1,41 @@
:root {
--ml-bg-light: rgb(240, 238, 246);
--ml-bg-dark: rgb(34, 40, 49);
}
html,
body {
min-height: 100svh;
width: 100%;
background-color: light-dark(var(--mantine-color-white), rgb(34, 40, 49));
background-color: light-dark(var(--ml-bg-light), var(--ml-bg-dark));
}
.__transition_fadeIn {
animation: fadeIn 0.25s ease;
opacity: 1;
scale: 1;
animation: fadeIn 0.15s ease both;
}
.__transition_fadeOut {
animation: fadeOut 0.25s ease;
opacity: 0;
scale: 0.9;
animation: fadeOut 0.15s ease both;
}
@keyframes fadeIn {
from {
opacity: 0;
scale: 0.9;
transform: scale(0.9);
}
to {
opacity: 1;
scale: 1;
transform: none;
}
}
@keyframes fadeOut {
from {
opacity: 1;
scale: 1;
transform: none;
}
to {
opacity: 0;
scale: 0.9;
transform: scale(0.9);
}
}