feat: recreate shared page

+ improve security by not exposing author's email
This commit is contained in:
Sonny
2024-11-09 02:25:44 +01:00
committed by Sonny
parent 798ff0fbe4
commit 83c1966946
17 changed files with 181 additions and 72 deletions

View File

@@ -1,27 +1,61 @@
import { Visibility } from '#enums/visibility';
import Collection from '#models/collection';
import Link from '#models/link';
import User from '#models/user';
import { getSharedCollectionValidator } from '#validators/shared_collection';
import type { HttpContext } from '@adonisjs/core/http';
class LinkWithoutFavoriteDto {
constructor(private link: Link) {}
toJson = () => ({
id: this.link.id,
name: this.link.name,
description: this.link.description,
url: this.link.url,
collectionId: this.link.collectionId,
createdAt: this.link.createdAt.toString(),
updatedAt: this.link.updatedAt.toString(),
});
}
class UserWithoutEmailDto {
constructor(private user: User) {}
toJson = () => ({
id: this.user.id,
fullname: this.user.name,
avatarUrl: this.user.avatarUrl,
isAdmin: this.user.isAdmin,
createdAt: this.user.createdAt.toString(),
updatedAt: this.user.updatedAt.toString(),
});
}
export default class SharedCollectionsController {
async index({ request, response }: HttpContext) {
async index({ request, inertia }: HttpContext) {
const { params } = await request.validateUsing(
getSharedCollectionValidator
);
const collection = await this.getSharedCollectionById(params.id);
console.log('shared page', collection);
// TODO: return view
return response.json(collection);
// return inertia.render('shared', { collection });
return inertia.render('shared', { collection });
}
private async getSharedCollectionById(id: Collection['id']) {
return await Collection.query()
const collection = await Collection.query()
.where('id', id)
.andWhere('visibility', Visibility.PUBLIC)
.preload('links')
.preload('author')
.firstOrFail();
return {
...collection.serialize(),
links: collection.links.map((link) =>
new LinkWithoutFavoriteDto(link).toJson()
),
author: new UserWithoutEmailDto(collection.author).toJson(),
};
}
}

View File

@@ -1,7 +1,7 @@
import { Card, Group, Text } from '@mantine/core';
import { ExternalLinkStyled } from '~/components/common/external_link_styled';
import LinkControls from '~/components/dashboard/link/link_controls';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import LinkFavicon from '~/components/dashboard/link/item/favicon/link_favicon';
import LinkControls from '~/components/dashboard/link/item/link_controls';
import { LinkWithCollection } from '~/types/app';
import styles from './favorite_item.module.css';

View File

@@ -10,10 +10,10 @@ import { MdFavorite, MdFavoriteBorder } from 'react-icons/md';
import { onFavorite } from '~/lib/favorite';
import { appendCollectionId, appendLinkId } from '~/lib/navigation';
import { useFavorites } from '~/stores/collection_store';
import { Link } from '~/types/app';
import { Link, PublicLink } from '~/types/app';
interface LinksControlsProps {
link: Link;
link: Link | PublicLink;
showGoToCollection?: boolean;
}
export default function LinkControls({
@@ -42,15 +42,17 @@ export default function LinkControls({
{t('go-to-collection')}
</Menu.Item>
)}
<Menu.Item
onClick={() =>
onFavorite(link.id, !link.favorite, onFavoriteCallback)
}
leftSection={link.favorite ? <MdFavorite /> : <MdFavoriteBorder />}
color="var(--mantine-color-yellow-7)"
>
{link.favorite ? t('remove-favorite') : t('add-favorite')}
</Menu.Item>
{'favorite' in link && (
<Menu.Item
onClick={() =>
onFavorite(link.id, !link.favorite, onFavoriteCallback)
}
leftSection={link.favorite ? <MdFavorite /> : <MdFavoriteBorder />}
color="var(--mantine-color-yellow-7)"
>
{link.favorite ? t('remove-favorite') : t('add-favorite')}
</Menu.Item>
)}
<Menu.Item
component={InertiaLink}
href={appendLinkId(route('link.edit-form').path, link.id)}

View File

@@ -1,19 +1,19 @@
import { Card, Group, Text } from '@mantine/core';
import { AiFillStar } from 'react-icons/ai';
import { ExternalLinkStyled } from '~/components/common/external_link_styled';
import LinkControls from '~/components/dashboard/link/link_controls';
import LinkFavicon from '~/components/dashboard/link/link_favicon';
import { Link } from '~/types/app';
import LinkFavicon from '~/components/dashboard/link/item/favicon/link_favicon';
import LinkControls from '~/components/dashboard/link/item/link_controls';
import type { LinkListProps } from '~/components/dashboard/link/list/link_list';
import { Link, PublicLink } from '~/types/app';
import styles from './link.module.css';
export default function LinkItem({
link,
showUserControls = false,
}: {
link: Link;
showUserControls: boolean;
}) {
const { name, url, description, favorite } = link;
interface LinkItemProps extends LinkListProps {
link: Link | PublicLink;
}
export function LinkItem({ link, hideMenu: hideMenu = false }: LinkItemProps) {
const { name, url, description } = link;
const showFavoriteIcon = !hideMenu && 'favorite' in link && link.favorite;
return (
<Card className={styles.linkWrapper} padding="sm" radius="sm" withBorder>
<Group className={styles.linkHeader} justify="center">
@@ -21,13 +21,12 @@ export default function LinkItem({
<ExternalLinkStyled href={url} style={{ flex: 1 }}>
<div className={styles.linkName}>
<Text lineClamp={1}>
{name}{' '}
{showUserControls && favorite && <AiFillStar color="gold" />}
{name} {showFavoriteIcon && <AiFillStar color="gold" />}
</Text>
</div>
<LinkItemURL url={url} />
</ExternalLinkStyled>
{showUserControls && <LinkControls link={link} />}
{!hideMenu && <LinkControls link={link} />}
</Group>
{description && (
<Text className={styles.linkDescription} c="dimmed" size="sm">

View File

@@ -0,0 +1,24 @@
import { Stack } from '@mantine/core';
import { LinkItem } from '~/components/dashboard/link/item/link_item';
import { NoLink } from '~/components/dashboard/link/no_link/no_link';
import { useActiveCollection } from '~/stores/collection_store';
export interface LinkListProps {
hideMenu?: boolean;
}
export function LinkList({ hideMenu = false }: LinkListProps) {
const { activeCollection } = useActiveCollection();
if (!activeCollection?.links || activeCollection.links.length === 0) {
return <NoLink hideMenu={hideMenu} />;
}
return (
<Stack gap="xs">
{activeCollection?.links.map((link) => (
<LinkItem link={link} key={link.id} hideMenu={hideMenu} />
))}
</Stack>
);
}

View File

@@ -2,11 +2,14 @@ import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Box, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import type { LinkListProps } from '~/components/dashboard/link/list/link_list';
import { appendCollectionId } from '~/lib/navigation';
import { useActiveCollection } from '~/stores/collection_store';
import styles from './no_link.module.css';
export function NoLink() {
interface NoLinkProps extends LinkListProps {}
export function NoLink({ hideMenu }: NoLinkProps) {
const { t } = useTranslation('common');
const { activeCollection } = useActiveCollection();
return (
@@ -23,15 +26,17 @@ export function NoLink() {
),
}}
/>
<Anchor
component={Link}
href={appendCollectionId(
route('link.create-form').path,
activeCollection?.id
)}
>
{t('link.create')}
</Anchor>
{!hideMenu && (
<Anchor
component={Link}
href={appendCollectionId(
route('link.create-form').path,
activeCollection?.id
)}
>
{t('link.create')}
</Anchor>
)}
</Box>
);
}

View File

@@ -2,8 +2,6 @@
"role": "Role",
"created_at": "Created at",
"last_seen_at": "Last seen at",
"admin": "Administrator",
"user": "User",
"users": "Users",
"stats": "Statistics"
}

View File

@@ -27,7 +27,8 @@
"edit": "Edit a collection",
"delete": "Delete a collection",
"delete-confirm": "Confirm deletion?",
"delete-description": "You must delete all links in this collection before you can delete this collection."
"delete-description": "You must delete all links in this collection before you can delete this collection.",
"managed-by": "Collection managed by <b>{{name}}</b>"
},
"form": {
"name": "Name",
@@ -55,6 +56,7 @@
"go-to-collection": "Go to collection",
"no-item-found": "No item found",
"admin": "Administrator",
"user": "User",
"search": "Search",
"search-with": "Search with",
"avatar": "{{name}}'s avatar",

View File

@@ -2,8 +2,6 @@
"role": "Rôle",
"created_at": "Création",
"last_seen_at": "Dernière connexion",
"admin": "Administrateur",
"user": "Utilisateur",
"users": "Utilisateurs",
"stats": "Statistiques"
}

View File

@@ -27,7 +27,8 @@
"edit": "Modifier une collection",
"delete": "Supprimer une collection",
"delete-confirm": "Confirmer la suppression ?",
"delete-description": "Vous devez supprimer tous les liens de cette collection avant de pouvoir supprimer cette collection"
"delete-description": "Vous devez supprimer tous les liens de cette collection avant de pouvoir supprimer cette collection",
"managed-by": "Collection gérée par <b>{{name}}</b>"
},
"form": {
"name": "Nom",
@@ -54,7 +55,8 @@
"favorites-appears-here": "Vos favoris apparaîtront ici",
"go-to-collection": "Voir la collection",
"no-item-found": "Aucun élément trouvé",
"admin": "Administrateur",
"admin": "Administrateur",
"user": "Utilisateur",
"search": "Rechercher",
"search-with": "Rechercher avec",
"avatar": "Avatar de {{name}}",

View File

@@ -1,13 +1,12 @@
import { router } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { AppShell, ScrollArea, Stack } from '@mantine/core';
import { AppShell, ScrollArea } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useEffect } from 'react';
import { DashboardAside } from '~/components/dashboard/dashboard_aside';
import { DashboardHeader } from '~/components/dashboard/dashboard_header';
import { DashboardNavbar } from '~/components/dashboard/dashboard_navbar';
import LinkItem from '~/components/dashboard/link/link_item';
import { NoLink } from '~/components/dashboard/link/no_link';
import { LinkList } from '~/components/dashboard/link/list/link_list';
import { MantineFooter } from '~/components/footer/footer';
import { useDisableOverflow } from '~/hooks/use_disable_overflow';
import useShortcut from '~/hooks/use_shortcut';
@@ -69,16 +68,16 @@ export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
}
);
const headerHeight = !!activeCollection?.description
? HEADER_SIZE_WITH_DESCRIPTION
: HEADER_SIZE_WITHOUT_DESCRIPTION;
const footerHeight = 45;
return (
<DashboardLayout>
<div className={classes.app_wrapper}>
<AppShell
layout="alt"
header={{
height: !!activeCollection?.description
? HEADER_SIZE_WITH_DESCRIPTION
: HEADER_SIZE_WITHOUT_DESCRIPTION,
}}
header={{ height: headerHeight }}
navbar={{
width: 300,
breakpoint: 'sm',
@@ -89,6 +88,7 @@ export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
breakpoint: 'md',
collapsed: { mobile: !openedAside },
}}
footer={{ height: footerHeight }}
classNames={{
aside: classes.ml_custom_class,
footer: classes.ml_custom_class,
@@ -103,20 +103,12 @@ export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
/>
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
<AppShell.Main>
{activeCollection?.links && activeCollection.links.length > 0 ? (
<ScrollArea
h="calc(100vh - var(--app-shell-header-height, 0px) - var(--app-shell-footer-height, 0px))"
p="md"
>
<Stack gap="xs">
{activeCollection?.links.map((link) => (
<LinkItem key={link.id} link={link} showUserControls />
))}
</Stack>
</ScrollArea>
) : (
<NoLink key={activeCollection?.id} />
)}
<ScrollArea
h="calc(100vh - var(--app-shell-header-height) - var(--app-shell-footer-height, 0px))"
p="md"
>
<LinkList />
</ScrollArea>
</AppShell.Main>
<DashboardAside isOpen={openedAside} toggle={toggleAside} />
<AppShell.Footer pl="xs" pr="xs">

48
inertia/pages/shared.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { Flex, Text } from '@mantine/core';
import { ReactNode, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { LinkList } from '~/components/dashboard/link/list/link_list';
import { ContentLayout } from '~/layouts/content_layout';
import { useCollectionsSetter } from '~/stores/collection_store';
import type { CollectionWithLinks, PublicUser } from '~/types/app';
interface SharedPageProps {
collection: CollectionWithLinks & { author: PublicUser };
}
function SharedPage({ collection }: SharedPageProps) {
const { t } = useTranslation('common');
const { setActiveCollection } = useCollectionsSetter();
useEffect(() => {
setActiveCollection(collection);
}, []);
return (
<>
<Flex direction="column">
<Text size="xl">{collection.name}</Text>
<Text size="sm" c="dimmed">
{collection.description}
</Text>
<Flex justify="flex-end">
<Text
size="sm"
c="dimmed"
mt="md"
mb="lg"
dangerouslySetInnerHTML={{
__html: t('collection.managed-by', {
name: collection.author.fullname,
}),
}}
/>
</Flex>
<LinkList hideMenu />
</Flex>
</>
);
}
SharedPage.layout = (page: ReactNode) => <ContentLayout>{page}</ContentLayout>;
export default SharedPage;

View File

@@ -14,6 +14,8 @@ type User = CommonBase & {
lastSeenAt: string;
};
type PublicUser = Omit<User, 'email'>;
type Users = User[];
type UserWithCollections = User & {
@@ -42,6 +44,9 @@ type LinkWithCollection = Link & {
collection: Collection;
};
type PublicLink = Omit<Link, 'favorite'>;
type PublicLinkWithCollection = Omit<Link, 'favorite'>;
type Collection = CommonBase & {
name: string;
description: string | null;