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 Collection from '#collections/models/collection';
|
||||||
import AppBaseModel from '#core/models/app_base_model';
|
import AppBaseModel from '#core/models/app_base_model';
|
||||||
import Link from '#links/models/link';
|
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 type { GoogleToken } from '@adonisjs/ally/types';
|
||||||
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
import { column, computed, hasMany } from '@adonisjs/lucid/orm';
|
||||||
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
import type { HasMany } from '@adonisjs/lucid/types/relations';
|
||||||
@@ -51,4 +53,15 @@ export default class User extends AppBaseModel {
|
|||||||
autoUpdate: true,
|
autoUpdate: true,
|
||||||
})
|
})
|
||||||
declare lastSeenAt: DateTime;
|
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';
|
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 { UserDropdown } from '~/components/common/floating_navbar/user_dropdown';
|
||||||
import { ExternalLinkUnstyled } from '~/components/common/links/external_link_unstyled';
|
import { ExternalLinkUnstyled } from '~/components/common/links/external_link_unstyled';
|
||||||
import { InternalLink } from '~/components/common/links/internal_link';
|
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 { useAuth } from '~/hooks/use_auth';
|
||||||
import classes from './floating_navbar.module.css';
|
import classes from './floating_navbar.module.css';
|
||||||
|
|
||||||
@@ -74,8 +76,8 @@ export function FloatingNavbar({ width }: FloatingNavbarProps) {
|
|||||||
</Group>
|
</Group>
|
||||||
<Group>
|
<Group>
|
||||||
{!isMobile && <Group>{links}</Group>}
|
{!isMobile && <Group>{links}</Group>}
|
||||||
{isMobile && <Burger opened={opened} onClick={handler.toggle} />}
|
|
||||||
{auth.isAuthenticated && <UserDropdown />}
|
{auth.isAuthenticated && <UserDropdown />}
|
||||||
|
{isMobile && <Burger opened={opened} onClick={handler.toggle} />}
|
||||||
{!auth.isAuthenticated && (
|
{!auth.isAuthenticated && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -101,6 +103,10 @@ export function FloatingNavbar({ width }: FloatingNavbarProps) {
|
|||||||
<Flex direction="column" gap="md">
|
<Flex direction="column" gap="md">
|
||||||
{links}
|
{links}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Group mt="md">
|
||||||
|
<ThemeSwitcher />
|
||||||
|
<LocaleSwitcher />
|
||||||
|
</Group>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</Box>
|
</Box>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -10,10 +10,6 @@
|
|||||||
var(--mantine-color-dark-8)
|
var(--mantine-color-dark-8)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.userActive {
|
.userActive {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Avatar, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
import { Avatar, Group, Menu, Text, UnstyledButton } from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
|
import { modals } from '@mantine/modals';
|
||||||
import cx from 'clsx';
|
import cx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { InternalLinkUnstyled } from '~/components/common/links/internal_link_unstyled';
|
||||||
|
import { UserPreferences } from '~/components/common/user_preferences/user_preferences';
|
||||||
import { useAuth } from '~/hooks/use_auth';
|
import { useAuth } from '~/hooks/use_auth';
|
||||||
import classes from './user_dropdown.module.css';
|
import classes from './user_dropdown.module.css';
|
||||||
|
|
||||||
@@ -12,6 +14,14 @@ export function UserDropdown() {
|
|||||||
const [userMenuOpened, { open: openUserMenu, close: closeUserMenu }] =
|
const [userMenuOpened, { open: openUserMenu, close: closeUserMenu }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const handlePreferencesModal = () => {
|
||||||
|
modals.open({
|
||||||
|
title: t('user-preferences'),
|
||||||
|
children: <UserPreferences />,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
width={260}
|
width={260}
|
||||||
@@ -49,12 +59,18 @@ export function UserDropdown() {
|
|||||||
href="/admin"
|
href="/admin"
|
||||||
color="red"
|
color="red"
|
||||||
>
|
>
|
||||||
{t('common:manage_users')}
|
{t('common:manage-users')}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Menu.Label>{t('common:settings')}</Menu.Label>
|
<Menu.Label>{t('common:settings')}</Menu.Label>
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<TbSettings size={16} />}
|
||||||
|
onClick={handlePreferencesModal}
|
||||||
|
>
|
||||||
|
{t('common:preferences')}
|
||||||
|
</Menu.Item>
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<TbLogout size={16} />}
|
leftSection={<TbLogout size={16} />}
|
||||||
component={InternalLinkUnstyled}
|
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 { CollectionWithLinks } from '~/types/app';
|
||||||
import classes from './collection_item.module.css';
|
import classes from './collection_item.module.css';
|
||||||
|
|
||||||
export default function CollectionItem({
|
interface CollectionItemProps {
|
||||||
collection,
|
|
||||||
}: {
|
|
||||||
collection: CollectionWithLinks;
|
collection: CollectionWithLinks;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function CollectionItem({ collection }: CollectionItemProps) {
|
||||||
const itemRef = useRef<HTMLAnchorElement>(null);
|
const itemRef = useRef<HTMLAnchorElement>(null);
|
||||||
const activeCollection = useActiveCollection();
|
const activeCollection = useActiveCollection();
|
||||||
const isActiveCollection = collection.id === activeCollection?.id;
|
const isActiveCollection = collection.id === activeCollection?.id;
|
||||||
@@ -34,7 +34,10 @@ export default function CollectionItem({
|
|||||||
title={collection.name}
|
title={collection.name}
|
||||||
>
|
>
|
||||||
<FolderIcon className={classes.linkIcon} />
|
<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}
|
{collection.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Link>
|
</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} />}
|
{!hideMenu && <LinkControls link={link} />}
|
||||||
</Group>
|
</Group>
|
||||||
{description && (
|
{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}
|
{description}
|
||||||
</Text>
|
</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 { LinkItem } from '~/components/dashboard/link/item/link_item';
|
||||||
import { NoLink } from '~/components/dashboard/link/no_link/no_link';
|
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 {
|
export interface LinkListProps {
|
||||||
hideMenu?: boolean;
|
hideMenu?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LinkList({ hideMenu = false }: LinkListProps) {
|
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 <NoLink hideMenu={hideMenu} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{activeCollection?.links.map((link) => (
|
<SimpleGrid cols={getColsByView(displayPreferences)} spacing="xs">
|
||||||
<LinkItem link={link} key={link.id} hideMenu={hideMenu} />
|
{links.map((link) => (
|
||||||
))}
|
<LinkItem link={link} key={link.id} hideMenu={hideMenu} />
|
||||||
|
))}
|
||||||
|
</SimpleGrid>
|
||||||
</Stack>
|
</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 { FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
|
import BackToDashboard from '~/components/common/navigation/back_to_dashboard';
|
||||||
|
import useSearchParam from '~/hooks/use_search_param';
|
||||||
import { FormLayout, FormLayoutProps } from '~/layouts/form_layout';
|
import { FormLayout, FormLayoutProps } from '~/layouts/form_layout';
|
||||||
import { Visibility } from '~/types/app';
|
import { Visibility } from '~/types/app';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export default function MantineFormCollection({
|
|||||||
...props
|
...props
|
||||||
}: FormCollectionProps) {
|
}: FormCollectionProps) {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
|
const collectionId = Number(useSearchParam('collectionId'));
|
||||||
|
|
||||||
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
const onSubmit = (event: FormEvent<HTMLFormElement>) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -36,7 +38,7 @@ export default function MantineFormCollection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormLayout handleSubmit={onSubmit} {...props}>
|
<FormLayout handleSubmit={onSubmit} collectionId={collectionId} {...props}>
|
||||||
<BackToDashboard disabled={props.disableHomeLink}>
|
<BackToDashboard disabled={props.disableHomeLink}>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('form.name')}
|
label={t('form.name')}
|
||||||
|
|||||||
@@ -2,15 +2,15 @@ import { ActionIcon, Anchor, CopyButton, Popover, Text } from '@mantine/core';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { TbShare3 } from 'react-icons/tb';
|
import { TbShare3 } from 'react-icons/tb';
|
||||||
import { Fragment } from 'react/jsx-runtime';
|
import { Fragment } from 'react/jsx-runtime';
|
||||||
|
import { useActiveCollection } from '~/hooks/collections/use_active_collection';
|
||||||
import { generateShareUrl } from '~/lib/navigation';
|
import { generateShareUrl } from '~/lib/navigation';
|
||||||
import { useActiveCollection } from '~/stores/collection_store';
|
|
||||||
import { Visibility } from '~/types/app';
|
import { Visibility } from '~/types/app';
|
||||||
|
|
||||||
const COPY_SUCCESS_TIMEOUT = 2_000;
|
const COPY_SUCCESS_TIMEOUT = 2_000;
|
||||||
|
|
||||||
export function ShareCollection() {
|
export function ShareCollection() {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const { activeCollection } = useActiveCollection();
|
const activeCollection = useActiveCollection();
|
||||||
if (
|
if (
|
||||||
activeCollection?.visibility !== Visibility.PUBLIC ||
|
activeCollection?.visibility !== Visibility.PUBLIC ||
|
||||||
typeof window === 'undefined'
|
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",
|
"go-to-collection": "Go to collection",
|
||||||
"no-item-found": "No item found",
|
"no-item-found": "No item found",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"manage_users": "Manage users",
|
"manage-users": "Manage users",
|
||||||
"user": "User",
|
"user": "User",
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
"search-with": "Search with",
|
"search-with": "Search with",
|
||||||
@@ -83,5 +83,13 @@
|
|||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
"no-results": "No results found",
|
"no-results": "No results found",
|
||||||
"click-to-copy": "Click on the following link to copy the shareable url",
|
"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",
|
"go-to-collection": "Voir la collection",
|
||||||
"no-item-found": "Aucun élément trouvé",
|
"no-item-found": "Aucun élément trouvé",
|
||||||
"admin": "Admin",
|
"admin": "Admin",
|
||||||
"manage_users": "Gestion utilisateurs",
|
"manage-users": "Gestion des utilisateurs",
|
||||||
"user": "Utilisateur",
|
"user": "Utilisateur",
|
||||||
"search": "Rechercher",
|
"search": "Rechercher",
|
||||||
"search-with": "Rechercher avec",
|
"search-with": "Rechercher avec",
|
||||||
@@ -83,5 +83,13 @@
|
|||||||
"loading": "Chargement...",
|
"loading": "Chargement...",
|
||||||
"no-results": "Aucun résultat trouvé",
|
"no-results": "Aucun résultat trouvé",
|
||||||
"click-to-copy": "Cliquez sur le lien suivant pour copier l'URL partageable",
|
"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,
|
rem,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
|
import { ModalsProvider } from '@mantine/modals';
|
||||||
import '@mantine/spotlight/styles.css';
|
import '@mantine/spotlight/styles.css';
|
||||||
import { createTuyau } from '@tuyau/client';
|
import { createTuyau } from '@tuyau/client';
|
||||||
import { TuyauProvider } from '@tuyau/inertia/react';
|
import { TuyauProvider } from '@tuyau/inertia/react';
|
||||||
@@ -119,7 +120,9 @@ export function BaseLayout({ children }: { children: ReactNode }) {
|
|||||||
return (
|
return (
|
||||||
<TuyauProvider client={tuyauClient}>
|
<TuyauProvider client={tuyauClient}>
|
||||||
<ColorSchemeScript />
|
<ColorSchemeScript />
|
||||||
<MantineProvider theme={customTheme}>{children}</MantineProvider>
|
<MantineProvider theme={customTheme}>
|
||||||
|
<ModalsProvider>{children}</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
</TuyauProvider>
|
</TuyauProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const Layout = ({ children }: PropsWithChildren) => (
|
|||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
width: LAYOUT_WIDTH,
|
width: LAYOUT_WIDTH,
|
||||||
marginInline: 'auto',
|
marginInline: 'auto',
|
||||||
marginBlock: rem(60),
|
marginBlock: rem(30),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{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 { Link } from '@inertiajs/react';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { route } from '@izzyjs/route/client';
|
||||||
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 {
|
import {
|
||||||
useActiveCollection,
|
Box,
|
||||||
useCollectionsSetter,
|
Button,
|
||||||
} from '~/stores/collection_store';
|
Divider,
|
||||||
import { CollectionWithLinks } from '~/types/app';
|
Group,
|
||||||
import classes from './dashboard.module.css';
|
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 {
|
export default function Dashboard() {
|
||||||
collections: CollectionWithLinks[];
|
const { t } = useTranslation();
|
||||||
activeCollection: CollectionWithLinks;
|
const { displayPreferences } = useDisplayPreferences();
|
||||||
}
|
const activeCollection = useActiveCollection();
|
||||||
|
|
||||||
const HEADER_SIZE_WITH_DESCRIPTION = 60;
|
const isMobile = useIsMobile();
|
||||||
const HEADER_SIZE_WITHOUT_DESCRIPTION = 50;
|
const isFavorite = !activeCollection?.id;
|
||||||
|
|
||||||
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;
|
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<Stack w="100%">
|
||||||
<div className={classes.app_wrapper}>
|
<Group justify="space-between">
|
||||||
<AppShell
|
<Tooltip label={t('coming-soon')}>
|
||||||
layout="alt"
|
<Input
|
||||||
header={{ height: headerHeight }}
|
placeholder={t('search')}
|
||||||
navbar={{
|
w={isMobile ? '100%' : '350px'}
|
||||||
width: 300,
|
disabled
|
||||||
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 }}
|
|
||||||
/>
|
/>
|
||||||
<DashboardNavbar isOpen={openedNavbar} toggle={toggleNavbar} />
|
</Tooltip>
|
||||||
<AppShell.Main>
|
{!isFavorite && (
|
||||||
<ScrollArea
|
<Group>
|
||||||
h="calc(100vh - var(--app-shell-header-height) - var(--app-shell-footer-height, 0px))"
|
{activeCollection?.visibility === Visibility.PUBLIC && (
|
||||||
p="md"
|
<SharedCollectionCopyLink />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Divider orientation="vertical" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
component={Link}
|
||||||
|
href={appendCollectionId(
|
||||||
|
route('collection.create-form').path,
|
||||||
|
activeCollection?.id
|
||||||
|
)}
|
||||||
|
size="xs"
|
||||||
>
|
>
|
||||||
<LinkList />
|
{t('collection.create')}
|
||||||
</ScrollArea>
|
</Button>
|
||||||
</AppShell.Main>
|
<Button
|
||||||
<DashboardAside isOpen={openedAside} toggle={toggleAside} />
|
variant="outline"
|
||||||
<AppShell.Footer pl="xs" pr="xs">
|
component={Link}
|
||||||
<MantineFooter />
|
href={appendCollectionId(
|
||||||
</AppShell.Footer>
|
route('collection.edit-form').path,
|
||||||
</AppShell>
|
activeCollection?.id
|
||||||
</div>
|
)}
|
||||||
</DashboardLayout>
|
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 />
|
||||||
|
</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';
|
import type { CollectionWithLinks, PublicUser } from '~/types/app';
|
||||||
|
|
||||||
interface SharedPageProps {
|
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');
|
const { t } = useTranslation('common');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex direction="column">
|
<Flex direction="column">
|
||||||
<Text size="xl">{collection.name}</Text>
|
<Text size="xl">{activeCollection.name}</Text>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{collection.description}
|
{activeCollection.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex justify="flex-end">
|
<Flex justify="flex-end">
|
||||||
<Text
|
<Text
|
||||||
@@ -24,7 +24,7 @@ export default function SharedPage({ collection }: SharedPageProps) {
|
|||||||
mb="lg"
|
mb="lg"
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: t('collection.managed-by', {
|
__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 = {
|
type CommonBase = {
|
||||||
id: number;
|
id: number;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -10,6 +12,7 @@ export type User = CommonBase & {
|
|||||||
avatarUrl: string;
|
avatarUrl: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
lastSeenAt: string;
|
lastSeenAt: string;
|
||||||
|
displayPreferences: DisplayPreferences;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PublicUser = Omit<User, 'email'>;
|
export type PublicUser = Omit<User, 'email'>;
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
"@izzyjs/route": "^1.2.0",
|
"@izzyjs/route": "^1.2.0",
|
||||||
"@mantine/core": "^8.2.3",
|
"@mantine/core": "^8.2.3",
|
||||||
"@mantine/hooks": "^8.2.3",
|
"@mantine/hooks": "^8.2.3",
|
||||||
|
"@mantine/modals": "^8.2.5",
|
||||||
"@mantine/spotlight": "^8.2.3",
|
"@mantine/spotlight": "^8.2.3",
|
||||||
"@tuyau/client": "^0.2.10",
|
"@tuyau/client": "^0.2.10",
|
||||||
"@tuyau/core": "^0.4.2",
|
"@tuyau/core": "^0.4.2",
|
||||||
|
|||||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
|||||||
'@mantine/hooks':
|
'@mantine/hooks':
|
||||||
specifier: ^8.2.3
|
specifier: ^8.2.3
|
||||||
version: 8.2.3(react@19.1.1)
|
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':
|
'@mantine/spotlight':
|
||||||
specifier: ^8.2.3
|
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)
|
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:
|
peerDependencies:
|
||||||
react: ^18.x || ^19.x
|
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':
|
'@mantine/spotlight@8.2.3':
|
||||||
resolution: {integrity: sha512-jorSsWwHDtEMOnylpQG91xcKMeYQM5GVPz5Wbif7hpQgWguRq/elEhF6DTpoPwLlMnNrAC27CV6WPa+p0Gc1WA==}
|
resolution: {integrity: sha512-jorSsWwHDtEMOnylpQG91xcKMeYQM5GVPz5Wbif7hpQgWguRq/elEhF6DTpoPwLlMnNrAC27CV6WPa+p0Gc1WA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6834,6 +6845,13 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
react: 19.1.1
|
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)':
|
'@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:
|
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/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';
|
import { api } from '#adonis/api';
|
||||||
|
|
||||||
export type ApiRouteName = (typeof api.routes)[number]['name'];
|
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