mirror of
https://github.com/Sonny93/my-links.git
synced 2025-12-08 14:43:24 +00:00
feat: add multiple way to show collections and links
This commit is contained in:
26
app/user/controllers/display_preferences_controller.ts
Normal file
26
app/user/controllers/display_preferences_controller.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDisplayPreferences } from '#shared/lib/display_preferences';
|
||||
import { updateDisplayPreferencesValidator } from '#user/validators/update_display_preferences';
|
||||
import { HttpContext } from '@adonisjs/core/http';
|
||||
|
||||
export default class DisplayPreferencesController {
|
||||
async update({ request, response, auth }: HttpContext) {
|
||||
const { displayPreferences } = await request.validateUsing(
|
||||
updateDisplayPreferencesValidator
|
||||
);
|
||||
const userPrefs = auth.user!.displayPreferences ?? {};
|
||||
const mergedPrefs = {
|
||||
linkListDisplay:
|
||||
displayPreferences.linkListDisplay ??
|
||||
userPrefs.linkListDisplay ??
|
||||
getDisplayPreferences().linkListDisplay,
|
||||
collectionListDisplay:
|
||||
displayPreferences.collectionListDisplay ??
|
||||
userPrefs.collectionListDisplay ??
|
||||
getDisplayPreferences().collectionListDisplay,
|
||||
};
|
||||
auth.user!.displayPreferences = mergedPrefs;
|
||||
console.log(auth.user!.displayPreferences);
|
||||
await auth.user!.save();
|
||||
return response.redirect().withQs().back();
|
||||
}
|
||||
}
|
||||
12
app/user/lib/index.ts
Normal file
12
app/user/lib/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { type DisplayPreferences } from '#shared/types/index';
|
||||
import User from '#user/models/user';
|
||||
|
||||
export function ensureDisplayPreferences(user: User): DisplayPreferences {
|
||||
const defaults: DisplayPreferences = {
|
||||
linkListDisplay: 'grid',
|
||||
collectionListDisplay: 'list',
|
||||
};
|
||||
|
||||
user.displayPreferences = { ...defaults, ...user.displayPreferences };
|
||||
return user.displayPreferences;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Collection from '#collections/models/collection';
|
||||
import AppBaseModel from '#core/models/app_base_model';
|
||||
import Link from '#links/models/link';
|
||||
import { type DisplayPreferences } from '#shared/types/index';
|
||||
import { ensureDisplayPreferences } from '#user/lib/index';
|
||||
import type { GoogleToken } from '@adonisjs/ally/types';
|
||||
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
||||
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
||||
@@ -51,4 +53,15 @@ export default class User extends AppBaseModel {
|
||||
autoUpdate: true,
|
||||
})
|
||||
declare lastSeenAt: DateTime;
|
||||
|
||||
@column({
|
||||
serialize: (value) => {
|
||||
if (typeof value === 'string') {
|
||||
return ensureDisplayPreferences(JSON.parse(value));
|
||||
}
|
||||
return value;
|
||||
},
|
||||
prepare: (value) => JSON.stringify(value),
|
||||
})
|
||||
declare displayPreferences: DisplayPreferences;
|
||||
}
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
import './user_display_preferences_route.js';
|
||||
import './user_theme_route.js';
|
||||
|
||||
10
app/user/routes/user_display_preferences_route.ts
Normal file
10
app/user/routes/user_display_preferences_route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { middleware } from '#start/kernel';
|
||||
import router from '@adonisjs/core/services/router';
|
||||
|
||||
const DisplayPreferencesController = () =>
|
||||
import('#user/controllers/display_preferences_controller');
|
||||
|
||||
router
|
||||
.post('/user/display-preferences', [DisplayPreferencesController, 'update'])
|
||||
.as('user.update-display-preferences')
|
||||
.middleware([middleware.auth()]);
|
||||
26
app/user/validators/update_display_preferences.ts
Normal file
26
app/user/validators/update_display_preferences.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
COLLECTION_LIST_DISPLAYS,
|
||||
DEFAULT_LIST_DISPLAY_PREFERENCES,
|
||||
LINK_LIST_DISPLAYS,
|
||||
} from '#shared/lib/display_preferences';
|
||||
import vine from '@vinejs/vine';
|
||||
|
||||
export const updateDisplayPreferencesValidator = vine.compile(
|
||||
vine.object({
|
||||
displayPreferences: vine.object({
|
||||
linkListDisplay: vine
|
||||
.enum(LINK_LIST_DISPLAYS)
|
||||
.optional()
|
||||
.transform(
|
||||
(value) => value ?? DEFAULT_LIST_DISPLAY_PREFERENCES.linkListDisplay
|
||||
),
|
||||
collectionListDisplay: vine
|
||||
.enum(COLLECTION_LIST_DISPLAYS)
|
||||
.optional()
|
||||
.transform(
|
||||
(value) =>
|
||||
value ?? DEFAULT_LIST_DISPLAY_PREFERENCES.collectionListDisplay
|
||||
),
|
||||
}),
|
||||
})
|
||||
);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { BaseSchema } from '@adonisjs/lucid/schema';
|
||||
|
||||
export default class extends BaseSchema {
|
||||
protected tableName = 'users';
|
||||
|
||||
async up() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.jsonb('display_preferences').defaultTo('{}');
|
||||
});
|
||||
}
|
||||
|
||||
async down() {
|
||||
this.schema.alterTable(this.tableName, (table) => {
|
||||
table.dropColumn('display_preferences');
|
||||
});
|
||||
}
|
||||
}
|
||||
66
inertia/components/common/combo_list/combo_list.tsx
Normal file
66
inertia/components/common/combo_list/combo_list.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { Combobox, Input, InputBase, useCombobox } from '@mantine/core';
|
||||
import { ComboListItem } from '~/components/common/combo_list/combo_list_item';
|
||||
|
||||
export type ValueWithIcon = {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export function ComboList({
|
||||
selectedValue,
|
||||
values,
|
||||
setValue,
|
||||
}: {
|
||||
selectedValue: string;
|
||||
values: ValueWithIcon[];
|
||||
setValue: (value: string) => void;
|
||||
}) {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const selectedOption = values.find((item) => item.value === selectedValue);
|
||||
|
||||
const options = values.map((item) => (
|
||||
<Combobox.Option value={item.value} key={item.value}>
|
||||
<ComboListItem emoji={item.icon} label={item.label} />
|
||||
</Combobox.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
withinPortal={false}
|
||||
onOptionSubmit={(val) => {
|
||||
setValue(val as string);
|
||||
combobox.closeDropdown();
|
||||
}}
|
||||
>
|
||||
<Combobox.Target>
|
||||
<InputBase
|
||||
component="button"
|
||||
type="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
onClick={() => combobox.toggleDropdown()}
|
||||
rightSectionPointerEvents="none"
|
||||
multiline
|
||||
>
|
||||
{selectedOption ? (
|
||||
<ComboListItem
|
||||
emoji={selectedOption.icon}
|
||||
label={selectedOption.label}
|
||||
/>
|
||||
) : (
|
||||
<Input.Placeholder>Pick value</Input.Placeholder>
|
||||
)}
|
||||
</InputBase>
|
||||
</Combobox.Target>
|
||||
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>{options}</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
16
inertia/components/common/combo_list/combo_list_item.tsx
Normal file
16
inertia/components/common/combo_list/combo_list_item.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Group, Text } from '@mantine/core';
|
||||
|
||||
export const ComboListItem = ({
|
||||
emoji,
|
||||
label,
|
||||
}: {
|
||||
emoji: React.ReactNode;
|
||||
label: string;
|
||||
}) => (
|
||||
<Group gap="xs" align="center">
|
||||
{emoji}
|
||||
<Text fz="sm" fw={500}>
|
||||
{label}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
@@ -19,6 +19,8 @@ import { useEffect } from 'react';
|
||||
import { UserDropdown } from '~/components/common/floating_navbar/user_dropdown';
|
||||
import { ExternalLinkUnstyled } from '~/components/common/links/external_link_unstyled';
|
||||
import { InternalLink } from '~/components/common/links/internal_link';
|
||||
import { LocaleSwitcher } from '~/components/common/locale_switcher';
|
||||
import { ThemeSwitcher } from '~/components/common/theme_switcher';
|
||||
import { useAuth } from '~/hooks/use_auth';
|
||||
import classes from './floating_navbar.module.css';
|
||||
|
||||
@@ -74,8 +76,8 @@ export function FloatingNavbar({ width }: FloatingNavbarProps) {
|
||||
</Group>
|
||||
<Group>
|
||||
{!isMobile && <Group>{links}</Group>}
|
||||
{isMobile && <Burger opened={opened} onClick={handler.toggle} />}
|
||||
{auth.isAuthenticated && <UserDropdown />}
|
||||
{isMobile && <Burger opened={opened} onClick={handler.toggle} />}
|
||||
{!auth.isAuthenticated && (
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -101,6 +103,10 @@ export function FloatingNavbar({ width }: FloatingNavbarProps) {
|
||||
<Flex direction="column" gap="md">
|
||||
{links}
|
||||
</Flex>
|
||||
<Group mt="md">
|
||||
<ThemeSwitcher />
|
||||
<LocaleSwitcher />
|
||||
</Group>
|
||||
</Drawer>
|
||||
</Box>
|
||||
</>
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
var(--mantine-color-dark-8)
|
||||
);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.userActive {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Avatar, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { modals } from '@mantine/modals';
|
||||
import cx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TbChevronDown, TbLogout, TbShield } from 'react-icons/tb';
|
||||
import { TbChevronDown, TbLogout, TbSettings, TbShield } from 'react-icons/tb';
|
||||
import { InternalLinkUnstyled } from '~/components/common/links/internal_link_unstyled';
|
||||
import { UserPreferences } from '~/components/common/user_preferences/user_preferences';
|
||||
import { useAuth } from '~/hooks/use_auth';
|
||||
import classes from './user_dropdown.module.css';
|
||||
|
||||
@@ -12,6 +14,14 @@ export function UserDropdown() {
|
||||
const [userMenuOpened, { open: openUserMenu, close: closeUserMenu }] =
|
||||
useDisclosure(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handlePreferencesModal = () => {
|
||||
modals.open({
|
||||
title: t('user-preferences'),
|
||||
children: <UserPreferences />,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu
|
||||
width={260}
|
||||
@@ -49,12 +59,18 @@ export function UserDropdown() {
|
||||
href="/admin"
|
||||
color="red"
|
||||
>
|
||||
{t('common:manage_users')}
|
||||
{t('common:manage-users')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Label>{t('common:settings')}</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<TbSettings size={16} />}
|
||||
onClick={handlePreferencesModal}
|
||||
>
|
||||
{t('common:preferences')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
leftSection={<TbLogout size={16} />}
|
||||
component={InternalLinkUnstyled}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LinkListDisplay } from '#shared/types/index';
|
||||
import { Fieldset, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ComboList } from '~/components/common/combo_list/combo_list';
|
||||
import { CollectionListSelector } from '~/components/dashboard/collection/collection_list_selector';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
import { useIsMobile } from '~/hooks/use_is_mobile';
|
||||
import { getLinkListDisplayOptions } from '~/lib/display_preferences';
|
||||
|
||||
export function UserPreferences() {
|
||||
const { displayPreferences, handleUpdateDisplayPreferences } =
|
||||
useDisplayPreferences();
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Fieldset legend={t('preferences')}>
|
||||
{isMobile && (
|
||||
<Text size="xs" c="orange" mb="sm">
|
||||
{t('preferences-description')}
|
||||
</Text>
|
||||
)}
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('display-preferences.collection-list-display')}
|
||||
</Text>
|
||||
<CollectionListSelector />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('display-preferences.link-list-display')}
|
||||
</Text>
|
||||
<ComboList
|
||||
selectedValue={displayPreferences.linkListDisplay}
|
||||
values={getLinkListDisplayOptions()}
|
||||
setValue={(value) =>
|
||||
handleUpdateDisplayPreferences({
|
||||
linkListDisplay: value as LinkListDisplay,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Fieldset>
|
||||
);
|
||||
}
|
||||
29
inertia/components/dashboard/collection/collection_list.tsx
Normal file
29
inertia/components/dashboard/collection/collection_list.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ScrollArea, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CollectionFavoriteItem } from '~/components/dashboard/collection/item/collection_favorite_item';
|
||||
import { CollectionItem } from '~/components/dashboard/collection/item/collection_item';
|
||||
import { useCollections } from '~/hooks/collections/use_collections';
|
||||
import { useIsMobile } from '~/hooks/use_is_mobile';
|
||||
import styles from './list/collection_list.module.css';
|
||||
|
||||
export function CollectionList() {
|
||||
const { t } = useTranslation();
|
||||
const collections = useCollections();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<Stack gap="xs" h="100%" w={isMobile ? '100%' : '350px'}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Text c="dimmed" ml="md" mb="sm">
|
||||
{t('collection.collections')} • {collections.length}
|
||||
</Text>
|
||||
<ScrollArea className={styles.collectionList}>
|
||||
<CollectionFavoriteItem />
|
||||
{collections.map((collection) => (
|
||||
<CollectionItem collection={collection} />
|
||||
))}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CollectionListDisplay } from '#shared/types/index';
|
||||
import { ComboList } from '~/components/common/combo_list/combo_list';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
import { getCollectionListDisplayOptions } from '~/lib/display_preferences';
|
||||
|
||||
export function CollectionListSelector() {
|
||||
const { displayPreferences, handleUpdateDisplayPreferences } =
|
||||
useDisplayPreferences();
|
||||
return (
|
||||
<ComboList
|
||||
selectedValue={displayPreferences.collectionListDisplay}
|
||||
values={getCollectionListDisplayOptions()}
|
||||
setValue={(value) =>
|
||||
handleUpdateDisplayPreferences({
|
||||
collectionListDisplay: value as CollectionListDisplay,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { router } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { Chip, Group, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import { useCollections } from '~/hooks/collections/use_collections';
|
||||
import { appendCollectionId } from '~/lib/navigation';
|
||||
|
||||
export function InlineCollectionList() {
|
||||
const { t } = useTranslation();
|
||||
const collections = useCollections();
|
||||
const activeCollection = useActiveCollection();
|
||||
|
||||
const handleCollectionChange = (value?: string) => {
|
||||
if (value) {
|
||||
router.visit(appendCollectionId(route('dashboard').path, Number(value)));
|
||||
return;
|
||||
}
|
||||
router.visit(route('dashboard').path);
|
||||
};
|
||||
|
||||
const fields = [
|
||||
{
|
||||
label: t('common:favorite'),
|
||||
value: 'favorite',
|
||||
},
|
||||
...collections.map((c) => ({
|
||||
label: (
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<>{c.name}</>
|
||||
<Text size="xs" c="dimmed">
|
||||
{c.links.length}
|
||||
</Text>
|
||||
</Group>
|
||||
),
|
||||
value: c.id.toString(),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<Group gap="xs" w="100%">
|
||||
{fields.map((field) => (
|
||||
<Chip
|
||||
key={field.value}
|
||||
checked={
|
||||
activeCollection?.id
|
||||
? activeCollection.id === Number(field.value)
|
||||
: field.value === 'favorite'
|
||||
}
|
||||
variant="light"
|
||||
onClick={() => handleCollectionChange(field.value)}
|
||||
>
|
||||
{field.label}
|
||||
</Chip>
|
||||
))}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TbStar, TbStarFilled } from 'react-icons/tb';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import classes from './collection_item.module.css';
|
||||
|
||||
export function CollectionFavoriteItem() {
|
||||
const { t } = useTranslation();
|
||||
const activeCollection = useActiveCollection();
|
||||
const isActiveCollection = !activeCollection?.id;
|
||||
const FolderIcon = isActiveCollection ? TbStarFilled : TbStar;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classes.link}
|
||||
data-active={isActiveCollection || undefined}
|
||||
href={route('dashboard').path}
|
||||
key="favorite"
|
||||
title="Favorite"
|
||||
>
|
||||
<FolderIcon className={classes.linkIcon} />
|
||||
<Text maw={'200px'} style={{ wordBreak: 'break-all' }}>
|
||||
{t('favorite')}
|
||||
</Text>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { appendCollectionId } from '~/lib/navigation';
|
||||
import { CollectionWithLinks } from '~/types/app';
|
||||
import classes from './collection_item.module.css';
|
||||
|
||||
export default function CollectionItem({
|
||||
collection,
|
||||
}: {
|
||||
interface CollectionItemProps {
|
||||
collection: CollectionWithLinks;
|
||||
}) {
|
||||
}
|
||||
|
||||
export function CollectionItem({ collection }: CollectionItemProps) {
|
||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||
const activeCollection = useActiveCollection();
|
||||
const isActiveCollection = collection.id === activeCollection?.id;
|
||||
@@ -34,7 +34,10 @@ export default function CollectionItem({
|
||||
title={collection.name}
|
||||
>
|
||||
<FolderIcon className={classes.linkIcon} />
|
||||
<Text lineClamp={1} maw={'200px'} style={{ wordBreak: 'break-all' }}>
|
||||
<Text
|
||||
lineClamp={1}
|
||||
style={{ wordBreak: 'break-all', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{collection.name}
|
||||
</Text>
|
||||
</Link>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Button, Drawer, Portal, rem, Text } from '@mantine/core';
|
||||
import { useDisclosure, useHeadroom } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TbFolder } from 'react-icons/tb';
|
||||
import { CollectionFavoriteItem } from '~/components/dashboard/collection/item/collection_favorite_item';
|
||||
import { useCollections } from '~/hooks/collections/use_collections';
|
||||
import { CollectionItem } from './item/collection_item';
|
||||
|
||||
export function MobileCollectionList() {
|
||||
const { t } = useTranslation();
|
||||
const [opened, handler] = useDisclosure();
|
||||
const collections = useCollections();
|
||||
const pinned = useHeadroom({ fixedAt: 0 });
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
opened={opened}
|
||||
onClose={handler.close}
|
||||
title={t('collection.collections', { count: collections.length })}
|
||||
>
|
||||
<CollectionFavoriteItem />
|
||||
{collections.map((collection) => (
|
||||
<CollectionItem collection={collection} />
|
||||
))}
|
||||
</Drawer>
|
||||
<Portal>
|
||||
<Button
|
||||
onClick={handler.open}
|
||||
variant="outline"
|
||||
size="xs"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
bottom: pinned ? rem(16) : rem(-100),
|
||||
width: `calc(100% - ${rem(16)} * 2)`,
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
transition: 'all 0.2s ease-in-out',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<TbFolder size={18} />
|
||||
<Text ml={4}>
|
||||
{t('collection.collections', { count: collections.length })}
|
||||
</Text>
|
||||
</Button>
|
||||
</Portal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Badge, CopyButton } from '@mantine/core';
|
||||
import { t } from 'i18next';
|
||||
import { TbCopy } from 'react-icons/tb';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import { useAppUrl } from '~/hooks/use_app_url';
|
||||
|
||||
const COPY_TIMEOUT = 3_000;
|
||||
|
||||
export function SharedCollectionCopyLink() {
|
||||
const appUrl = useAppUrl();
|
||||
const activeCollection = useActiveCollection();
|
||||
|
||||
if (!activeCollection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copyUrl = `${appUrl}/shared/${activeCollection.id}`;
|
||||
return (
|
||||
<CopyButton value={copyUrl} timeout={COPY_TIMEOUT}>
|
||||
{({ copied, copy }) => (
|
||||
<Badge
|
||||
variant={copied ? 'filled' : 'light'}
|
||||
onClick={copy}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{copied ? t('success-copy') : t('visibility.public')}
|
||||
{!copied && <TbCopy style={{ marginLeft: 4 }} />}
|
||||
</Badge>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,13 @@ export function LinkItem({ link, hideMenu: hideMenu = false }: LinkItemProps) {
|
||||
{!hideMenu && <LinkControls link={link} />}
|
||||
</Group>
|
||||
{description && (
|
||||
<Text c="dimmed" size="sm" mt="xs" lineClamp={3}>
|
||||
<Text
|
||||
c="dimmed"
|
||||
size="sm"
|
||||
mt="xs"
|
||||
lineClamp={3}
|
||||
style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -1,24 +1,52 @@
|
||||
import { Stack } from '@mantine/core';
|
||||
import { DisplayPreferences } from '#shared/types/index';
|
||||
import { SimpleGrid, Stack, StyleProp, useCombobox } 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';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import { useFavoriteLinks } from '~/hooks/collections/use_favorite_links';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
|
||||
export interface LinkListProps {
|
||||
hideMenu?: boolean;
|
||||
}
|
||||
|
||||
export function LinkList({ hideMenu = false }: LinkListProps) {
|
||||
const { activeCollection } = useActiveCollection();
|
||||
const activeCollection = useActiveCollection();
|
||||
const favoriteLinks = useFavoriteLinks();
|
||||
const { displayPreferences } = useDisplayPreferences();
|
||||
|
||||
if (!activeCollection?.links || activeCollection.links.length === 0) {
|
||||
const combobox = useCombobox({
|
||||
onDropdownClose: () => combobox.resetSelectedOption(),
|
||||
});
|
||||
|
||||
const links = activeCollection?.links || favoriteLinks;
|
||||
|
||||
if (activeCollection?.links.length === 0) {
|
||||
return <NoLink hideMenu={hideMenu} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{activeCollection?.links.map((link) => (
|
||||
<SimpleGrid cols={getColsByView(displayPreferences)} spacing="xs">
|
||||
{links.map((link) => (
|
||||
<LinkItem link={link} key={link.id} hideMenu={hideMenu} />
|
||||
))}
|
||||
</SimpleGrid>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
function getColsByView(
|
||||
displayPreferences: DisplayPreferences
|
||||
): StyleProp<number> {
|
||||
const { linkListDisplay } = displayPreferences;
|
||||
|
||||
if (linkListDisplay === 'grid') {
|
||||
return {
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
};
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Box, Group, SegmentedControl, Text, TextInput } from '@mantine/core';
|
||||
import { FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
|
||||
import useSearchParam from '~/hooks/use_search_param';
|
||||
import { FormLayout, FormLayoutProps } from '~/layouts/form_layout';
|
||||
import { Visibility } from '~/types/app';
|
||||
|
||||
@@ -29,6 +30,7 @@ export default function MantineFormCollection({
|
||||
...props
|
||||
}: FormCollectionProps) {
|
||||
const { t } = useTranslation('common');
|
||||
const collectionId = Number(useSearchParam('collectionId'));
|
||||
|
||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -36,7 +38,7 @@ export default function MantineFormCollection({
|
||||
};
|
||||
|
||||
return (
|
||||
<FormLayout handleSubmit={onSubmit} {...props}>
|
||||
<FormLayout handleSubmit={onSubmit} collectionId={collectionId} {...props}>
|
||||
<BackToDashboard disabled={props.disableHomeLink}>
|
||||
<TextInput
|
||||
label={t('form.name')}
|
||||
|
||||
@@ -2,15 +2,15 @@ import { ActionIcon, Anchor, CopyButton, Popover, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TbShare3 } from 'react-icons/tb';
|
||||
import { Fragment } from 'react/jsx-runtime';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import { generateShareUrl } from '~/lib/navigation';
|
||||
import { useActiveCollection } from '~/stores/collection_store';
|
||||
import { Visibility } from '~/types/app';
|
||||
|
||||
const COPY_SUCCESS_TIMEOUT = 2_000;
|
||||
|
||||
export function ShareCollection() {
|
||||
const { t } = useTranslation('common');
|
||||
const { activeCollection } = useActiveCollection();
|
||||
const activeCollection = useActiveCollection();
|
||||
if (
|
||||
activeCollection?.visibility !== Visibility.PUBLIC ||
|
||||
typeof window === 'undefined'
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { IoListOutline } from 'react-icons/io5';
|
||||
import { TbHash } from 'react-icons/tb';
|
||||
import { ValueWithIcon } from '~/components/common/combo_list/combo_list';
|
||||
import { usePersisted } from '~/hooks/use_persisted';
|
||||
|
||||
const listDisplayOptions: ValueWithIcon[] = [
|
||||
{
|
||||
label: 'Inline',
|
||||
value: 'inline',
|
||||
icon: <TbHash size={20} />,
|
||||
},
|
||||
{
|
||||
label: 'List',
|
||||
value: 'list',
|
||||
icon: <IoListOutline size={20} />,
|
||||
},
|
||||
];
|
||||
type ListDisplay = (typeof listDisplayOptions)[number]['value'];
|
||||
|
||||
export const useCollectionListSelector = () => {
|
||||
const [listDisplay, setListDisplay] = usePersisted<ListDisplay>(
|
||||
'inline',
|
||||
'list'
|
||||
);
|
||||
|
||||
return {
|
||||
listDisplay,
|
||||
listDisplayOptions,
|
||||
setListDisplay,
|
||||
};
|
||||
};
|
||||
7
inertia/hooks/use_app_url.tsx
Normal file
7
inertia/hooks/use_app_url.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { PageProps } from '@adonisjs/inertia/types';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
|
||||
export function useAppUrl() {
|
||||
const { props } = usePage<PageProps & { appUrl: string }>();
|
||||
return props.appUrl;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export const useDisableOverflow = () =>
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = 'auto';
|
||||
};
|
||||
}, []);
|
||||
26
inertia/hooks/use_display_preferences.tsx
Normal file
26
inertia/hooks/use_display_preferences.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { getDisplayPreferences } from '#shared/lib/display_preferences';
|
||||
import { DisplayPreferences } from '#shared/types/index';
|
||||
import { router } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import { useAuth } from '~/hooks/use_auth';
|
||||
|
||||
export const useDisplayPreferences = () => {
|
||||
const { user } = useAuth();
|
||||
const displayPreferences = getDisplayPreferences(user?.displayPreferences);
|
||||
|
||||
const handleUpdateDisplayPreferences = (
|
||||
displayPreferences: Partial<DisplayPreferences>
|
||||
) => {
|
||||
router.visit(route('user.update-display-preferences').path, {
|
||||
method: 'post',
|
||||
data: {
|
||||
displayPreferences,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
displayPreferences,
|
||||
handleUpdateDisplayPreferences,
|
||||
};
|
||||
};
|
||||
3
inertia/hooks/use_is_mobile.tsx
Normal file
3
inertia/hooks/use_is_mobile.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { useMediaQuery } from '@mantine/hooks';
|
||||
|
||||
export const useIsMobile = () => useMediaQuery('(max-width: 768px)');
|
||||
@@ -56,7 +56,7 @@
|
||||
"go-to-collection": "Go to collection",
|
||||
"no-item-found": "No item found",
|
||||
"admin": "Admin",
|
||||
"manage_users": "Manage users",
|
||||
"manage-users": "Manage users",
|
||||
"user": "User",
|
||||
"search": "Search",
|
||||
"search-with": "Search with",
|
||||
@@ -83,5 +83,13 @@
|
||||
"loading": "Loading...",
|
||||
"no-results": "No results found",
|
||||
"click-to-copy": "Click on the following link to copy the shareable url",
|
||||
"success-copy": "Link copied to clipboard"
|
||||
"success-copy": "Link copied to clipboard",
|
||||
"user-preferences": "User preferences",
|
||||
"preferences": "Preferences",
|
||||
"preferences-description": "Display preferences do not apply on mobile",
|
||||
"display-preferences": {
|
||||
"collection-list-display": "Collection list display",
|
||||
"link-list-display": "Link list display"
|
||||
},
|
||||
"coming-soon": "Under development"
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"go-to-collection": "Voir la collection",
|
||||
"no-item-found": "Aucun élément trouvé",
|
||||
"admin": "Admin",
|
||||
"manage_users": "Gestion utilisateurs",
|
||||
"manage-users": "Gestion des utilisateurs",
|
||||
"user": "Utilisateur",
|
||||
"search": "Rechercher",
|
||||
"search-with": "Rechercher avec",
|
||||
@@ -83,5 +83,13 @@
|
||||
"loading": "Chargement...",
|
||||
"no-results": "Aucun résultat trouvé",
|
||||
"click-to-copy": "Cliquez sur le lien suivant pour copier l'URL partageable",
|
||||
"success-copy": "Link copié dans le presse-papiers"
|
||||
"success-copy": "Link copié dans le presse-papiers",
|
||||
"user-preferences": "Préférences utilisateur",
|
||||
"preferences": "Préférences",
|
||||
"preferences-description": "Les préférences d'affichage ne s'appliquent pas sur mobile",
|
||||
"display-preferences": {
|
||||
"collection-list-display": "Affichage de la liste des collections",
|
||||
"link-list-display": "Affichage de la liste des liens"
|
||||
},
|
||||
"coming-soon": "En cours de développement"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import '@mantine/core/styles.css';
|
||||
import { ModalsProvider } from '@mantine/modals';
|
||||
import '@mantine/spotlight/styles.css';
|
||||
import { createTuyau } from '@tuyau/client';
|
||||
import { TuyauProvider } from '@tuyau/inertia/react';
|
||||
@@ -119,7 +120,9 @@ export function BaseLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<TuyauProvider client={tuyauClient}>
|
||||
<ColorSchemeScript />
|
||||
<MantineProvider theme={customTheme}>{children}</MantineProvider>
|
||||
<MantineProvider theme={customTheme}>
|
||||
<ModalsProvider>{children}</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</TuyauProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ const Layout = ({ children }: PropsWithChildren) => (
|
||||
maxWidth: '100%',
|
||||
width: LAYOUT_WIDTH,
|
||||
marginInline: 'auto',
|
||||
marginBlock: rem(60),
|
||||
marginBlock: rem(30),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
34
inertia/lib/display_preferences.tsx
Normal file
34
inertia/lib/display_preferences.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import {
|
||||
COLLECTION_LIST_DISPLAYS,
|
||||
LINK_LIST_DISPLAYS,
|
||||
} from '#shared/lib/display_preferences';
|
||||
import { AiOutlineFolder } from 'react-icons/ai';
|
||||
import { IoGridOutline } from 'react-icons/io5';
|
||||
import { TbList } from 'react-icons/tb';
|
||||
import { ValueWithIcon } from '~/components/common/combo_list/combo_list';
|
||||
|
||||
const collectionListDisplayIcons = {
|
||||
list: <TbList size={20} />,
|
||||
inline: <AiOutlineFolder size={20} />,
|
||||
} as const;
|
||||
|
||||
export function getCollectionListDisplayOptions(): ValueWithIcon[] {
|
||||
return COLLECTION_LIST_DISPLAYS.map((display) => ({
|
||||
label: display,
|
||||
value: display,
|
||||
icon: collectionListDisplayIcons[display],
|
||||
}));
|
||||
}
|
||||
|
||||
const linkListDisplayIcons = {
|
||||
list: <TbList size={20} />,
|
||||
grid: <IoGridOutline size={20} />,
|
||||
} as const;
|
||||
|
||||
export function getLinkListDisplayOptions(): ValueWithIcon[] {
|
||||
return LINK_LIST_DISPLAYS.map((display) => ({
|
||||
label: display,
|
||||
value: display,
|
||||
icon: linkListDisplayIcons[display],
|
||||
}));
|
||||
}
|
||||
@@ -1,98 +1,117 @@
|
||||
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 { 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';
|
||||
import { DashboardLayout } from '~/layouts/dashboard_layout';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import { route } from '@izzyjs/route/client';
|
||||
import {
|
||||
useActiveCollection,
|
||||
useCollectionsSetter,
|
||||
} from '~/stores/collection_store';
|
||||
import { CollectionWithLinks } from '~/types/app';
|
||||
import classes from './dashboard.module.css';
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Input,
|
||||
Stack,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CollectionList } from '~/components/dashboard/collection/collection_list';
|
||||
import { InlineCollectionList } from '~/components/dashboard/collection/inline_collection_list';
|
||||
import { MobileCollectionList } from '~/components/dashboard/collection/mobile_collection_list';
|
||||
import { SharedCollectionCopyLink } from '~/components/dashboard/collection/shared_collection_copy_link';
|
||||
import { LinkList } from '~/components/dashboard/link/list/link_list';
|
||||
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
|
||||
import { useIsMobile } from '~/hooks/use_is_mobile';
|
||||
import { appendCollectionId } from '~/lib/navigation';
|
||||
import { Visibility } from '~/types/app';
|
||||
|
||||
interface DashboardPageProps {
|
||||
collections: CollectionWithLinks[];
|
||||
activeCollection: CollectionWithLinks;
|
||||
}
|
||||
export default function Dashboard() {
|
||||
const { t } = useTranslation();
|
||||
const { displayPreferences } = useDisplayPreferences();
|
||||
const activeCollection = useActiveCollection();
|
||||
|
||||
const HEADER_SIZE_WITH_DESCRIPTION = 60;
|
||||
const HEADER_SIZE_WITHOUT_DESCRIPTION = 50;
|
||||
|
||||
export default function MantineDashboard(props: Readonly<DashboardPageProps>) {
|
||||
const [openedNavbar, { toggle: toggleNavbar, close: closeNavbar }] =
|
||||
useDisclosure();
|
||||
const [openedAside, { toggle: toggleAside, close: closeAside }] =
|
||||
useDisclosure();
|
||||
|
||||
const { activeCollection } = useActiveCollection();
|
||||
const { _setCollections, setActiveCollection } = useCollectionsSetter();
|
||||
|
||||
useShortcut('ESCAPE_KEY', () => {
|
||||
closeNavbar();
|
||||
closeAside();
|
||||
});
|
||||
|
||||
useDisableOverflow();
|
||||
|
||||
useEffect(() => {
|
||||
_setCollections(props.collections);
|
||||
setActiveCollection(props.activeCollection);
|
||||
}, []);
|
||||
|
||||
const headerHeight = !!activeCollection?.description
|
||||
? HEADER_SIZE_WITH_DESCRIPTION
|
||||
: HEADER_SIZE_WITHOUT_DESCRIPTION;
|
||||
const footerHeight = 45;
|
||||
const isMobile = useIsMobile();
|
||||
const isFavorite = !activeCollection?.id;
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div className={classes.app_wrapper}>
|
||||
<AppShell
|
||||
layout="alt"
|
||||
header={{ height: headerHeight }}
|
||||
navbar={{
|
||||
width: 300,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !openedNavbar },
|
||||
}}
|
||||
aside={{
|
||||
width: 300,
|
||||
breakpoint: 'md',
|
||||
collapsed: { mobile: !openedAside },
|
||||
}}
|
||||
footer={{ height: footerHeight }}
|
||||
classNames={{
|
||||
aside: classes.ml_custom_class,
|
||||
footer: classes.ml_custom_class,
|
||||
navbar: classes.ml_custom_class,
|
||||
header: classes.ml_custom_class,
|
||||
}}
|
||||
className={classes.app_shell}
|
||||
>
|
||||
<DashboardHeader
|
||||
navbar={{ opened: openedNavbar, toggle: toggleNavbar }}
|
||||
aside={{ opened: openedAside, toggle: toggleAside }}
|
||||
<Stack w="100%">
|
||||
<Group justify="space-between">
|
||||
<Tooltip label={t('coming-soon')}>
|
||||
<Input
|
||||
placeholder={t('search')}
|
||||
w={isMobile ? '100%' : '350px'}
|
||||
disabled
|
||||
/>
|
||||
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
|
||||
<AppShell.Main>
|
||||
<ScrollArea
|
||||
h="calc(100vh - var(--app-shell-header-height) - var(--app-shell-footer-height, 0px))"
|
||||
p="md"
|
||||
</Tooltip>
|
||||
{!isFavorite && (
|
||||
<Group>
|
||||
{activeCollection?.visibility === Visibility.PUBLIC && (
|
||||
<SharedCollectionCopyLink />
|
||||
)}
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
variant="outline"
|
||||
component={Link}
|
||||
href={appendCollectionId(
|
||||
route('collection.create-form').path,
|
||||
activeCollection?.id
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{t('collection.create')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
component={Link}
|
||||
href={appendCollectionId(
|
||||
route('collection.edit-form').path,
|
||||
activeCollection?.id
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{t('collection.edit')}
|
||||
</Button>
|
||||
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
variant="light"
|
||||
component={Link}
|
||||
href={appendCollectionId(
|
||||
route('link.create-form').path,
|
||||
activeCollection?.id
|
||||
)}
|
||||
size="xs"
|
||||
>
|
||||
{t('link.create')}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
{displayPreferences.collectionListDisplay === 'inline' && !isMobile && (
|
||||
<InlineCollectionList />
|
||||
)}
|
||||
<Group
|
||||
wrap="nowrap"
|
||||
align="flex-start"
|
||||
style={{ flexDirection: isMobile ? 'column-reverse' : 'row' }}
|
||||
flex={1}
|
||||
w="100%"
|
||||
>
|
||||
<Box w="100%">
|
||||
{activeCollection?.description && (
|
||||
<Text
|
||||
size="sm"
|
||||
c="dimmed"
|
||||
mb="md"
|
||||
style={{ wordBreak: 'break-word', whiteSpace: 'pre-line' }}
|
||||
>
|
||||
{activeCollection.description}
|
||||
</Text>
|
||||
)}
|
||||
<LinkList />
|
||||
</ScrollArea>
|
||||
</AppShell.Main>
|
||||
<DashboardAside isOpen={openedAside} toggle={toggleAside} />
|
||||
<AppShell.Footer pl="xs" pr="xs">
|
||||
<MantineFooter />
|
||||
</AppShell.Footer>
|
||||
</AppShell>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</Box>
|
||||
{displayPreferences.collectionListDisplay === 'list' && !isMobile && (
|
||||
<CollectionList />
|
||||
)}
|
||||
</Group>
|
||||
{isMobile && <MobileCollectionList />}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,17 +4,17 @@ import { LinkList } from '~/components/dashboard/link/list/link_list';
|
||||
import type { CollectionWithLinks, PublicUser } from '~/types/app';
|
||||
|
||||
interface SharedPageProps {
|
||||
collection: CollectionWithLinks & { author: PublicUser };
|
||||
activeCollection: CollectionWithLinks & { author: PublicUser };
|
||||
}
|
||||
|
||||
export default function SharedPage({ collection }: SharedPageProps) {
|
||||
export default function SharedPage({ activeCollection }: SharedPageProps) {
|
||||
const { t } = useTranslation('common');
|
||||
return (
|
||||
<>
|
||||
<Flex direction="column">
|
||||
<Text size="xl">{collection.name}</Text>
|
||||
<Text size="xl">{activeCollection.name}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{collection.description}
|
||||
{activeCollection.description}
|
||||
</Text>
|
||||
<Flex justify="flex-end">
|
||||
<Text
|
||||
@@ -24,7 +24,7 @@ export default function SharedPage({ collection }: SharedPageProps) {
|
||||
mb="lg"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: t('collection.managed-by', {
|
||||
name: collection.author.fullname,
|
||||
name: activeCollection.author.fullname,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DisplayPreferences } from '#shared/types/index';
|
||||
|
||||
type CommonBase = {
|
||||
id: number;
|
||||
createdAt: string;
|
||||
@@ -10,6 +12,7 @@ export type User = CommonBase & {
|
||||
avatarUrl: string;
|
||||
isAdmin: boolean;
|
||||
lastSeenAt: string;
|
||||
displayPreferences: DisplayPreferences;
|
||||
};
|
||||
|
||||
export type PublicUser = Omit<User, 'email'>;
|
||||
|
||||
@@ -83,6 +83,7 @@
|
||||
"@izzyjs/route": "^1.2.0",
|
||||
"@mantine/core": "^8.2.3",
|
||||
"@mantine/hooks": "^8.2.3",
|
||||
"@mantine/modals": "^8.2.5",
|
||||
"@mantine/spotlight": "^8.2.3",
|
||||
"@tuyau/client": "^0.2.10",
|
||||
"@tuyau/core": "^0.4.2",
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
||||
'@mantine/hooks':
|
||||
specifier: ^8.2.3
|
||||
version: 8.2.3(react@19.1.1)
|
||||
'@mantine/modals':
|
||||
specifier: ^8.2.5
|
||||
version: 8.2.5(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@mantine/spotlight':
|
||||
specifier: ^8.2.3
|
||||
version: 8.2.3(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
@@ -1451,6 +1454,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18.x || ^19.x
|
||||
|
||||
'@mantine/modals@8.2.5':
|
||||
resolution: {integrity: sha512-IUv/3kbnR8XCG5lFkgWtVbYJ+PsmGRHGSQs6AKgA8YrSKsFKYitXmhWbLzs9wRi/rWbYlUxkt9b3caCPAZe9IA==}
|
||||
peerDependencies:
|
||||
'@mantine/core': 8.2.5
|
||||
'@mantine/hooks': 8.2.5
|
||||
react: ^18.x || ^19.x
|
||||
react-dom: ^18.x || ^19.x
|
||||
|
||||
'@mantine/spotlight@8.2.3':
|
||||
resolution: {integrity: sha512-jorSsWwHDtEMOnylpQG91xcKMeYQM5GVPz5Wbif7hpQgWguRq/elEhF6DTpoPwLlMnNrAC27CV6WPa+p0Gc1WA==}
|
||||
peerDependencies:
|
||||
@@ -6834,6 +6845,13 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.1.1
|
||||
|
||||
'@mantine/modals@8.2.5(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@mantine/core': 8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
'@mantine/hooks': 8.2.3(react@19.1.1)
|
||||
react: 19.1.1
|
||||
react-dom: 19.1.1(react@19.1.1)
|
||||
|
||||
'@mantine/spotlight@8.2.3(@mantine/core@8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(@mantine/hooks@8.2.3(react@19.1.1))(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
|
||||
dependencies:
|
||||
'@mantine/core': 8.2.3(@mantine/hooks@8.2.3(react@19.1.1))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
|
||||
|
||||
18
shared/lib/display_preferences.ts
Normal file
18
shared/lib/display_preferences.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { DisplayPreferences } from '#shared/types/index';
|
||||
|
||||
export const COLLECTION_LIST_DISPLAYS = ['list', 'inline'] as const;
|
||||
export const LINK_LIST_DISPLAYS = ['list', 'grid'] as const;
|
||||
|
||||
export const DEFAULT_LIST_DISPLAY_PREFERENCES: DisplayPreferences = {
|
||||
linkListDisplay: LINK_LIST_DISPLAYS[0],
|
||||
collectionListDisplay: COLLECTION_LIST_DISPLAYS[0],
|
||||
} as const;
|
||||
|
||||
export function getDisplayPreferences(
|
||||
displayPreferences: Partial<DisplayPreferences> = DEFAULT_LIST_DISPLAY_PREFERENCES
|
||||
): DisplayPreferences {
|
||||
return {
|
||||
...DEFAULT_LIST_DISPLAY_PREFERENCES,
|
||||
...displayPreferences,
|
||||
} as const;
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
import { api } from '#adonis/api';
|
||||
|
||||
export type ApiRouteName = (typeof api.routes)[number]['name'];
|
||||
|
||||
export type CollectionListDisplay = 'list' | 'inline';
|
||||
export type LinkListDisplay = 'list' | 'grid';
|
||||
|
||||
export type DisplayPreferences = {
|
||||
linkListDisplay: LinkListDisplay;
|
||||
collectionListDisplay: CollectionListDisplay;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user