2 Commits

Author SHA1 Message Date
Sonny
376e9e32c3 feat: create dedicated settings page instead of creating many modals 2025-08-21 15:59:29 +02:00
Sonny
c2a1d06008 refactor: add new small content layout 2025-08-21 15:44:29 +02:00
21 changed files with 291 additions and 160 deletions

View File

@@ -0,0 +1,10 @@
import { HttpContext } from '@adonisjs/core/http';
export default class ShowUserSettingsController {
public async render({ auth, inertia }: HttpContext) {
const user = await auth.authenticate();
return inertia.render('user_settings/show', {
user,
});
}
}

View File

@@ -0,0 +1 @@
import './user_settings_routes.js';

View File

@@ -0,0 +1,8 @@
import router from '@adonisjs/core/services/router';
const ShowUserSettingsController = () =>
import('#user_settings/controllers/show_user_settings_controller');
router
.get('/user/settings', [ShowUserSettingsController, 'render'])
.as('user.settings');

View File

@@ -1,11 +1,9 @@
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, 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';
@@ -15,13 +13,6 @@ export function UserDropdown() {
useDisclosure(false);
const { t } = useTranslation();
const handlePreferencesModal = () => {
modals.open({
title: t('user-preferences'),
children: <UserPreferences />,
});
};
return (
<Menu
width={260}
@@ -64,12 +55,13 @@ export function UserDropdown() {
</>
)}
<Menu.Label>{t('common:settings')}</Menu.Label>
<Menu.Label>{t('common:user')}</Menu.Label>
<Menu.Item
leftSection={<TbSettings size={16} />}
onClick={handlePreferencesModal}
component={InternalLinkUnstyled}
href="/user/settings"
>
{t('common:preferences')}
{t('common:settings')}
</Menu.Item>
<Menu.Item
leftSection={<TbLogout size={16} />}

View File

@@ -0,0 +1,36 @@
.list {
position: relative;
margin-bottom: var(--mantine-spacing-md);
}
.indicator {
z-index: -1 !important;
background-color: var(--mantine-color-white);
border-radius: var(--mantine-radius-md);
border: 1px solid var(--mantine-color-gray-2);
box-shadow: var(--mantine-shadow-sm);
@mixin dark {
background-color: var(--mantine-color-dark-6);
border-color: var(--mantine-color-dark-4);
}
}
.tab {
z-index: 1;
font-weight: 500;
transition: color 100ms ease;
color: var(--mantine-color-gray-7);
&[data-active] {
color: var(--mantine-color-black);
}
@mixin dark {
color: var(--mantine-color-dark-1);
&[data-active] {
color: var(--mantine-color-white);
}
}
}

View File

@@ -0,0 +1,79 @@
import {
FloatingIndicator,
Indicator,
Tabs as MantineTabs,
Stack,
} from '@mantine/core';
import { useState } from 'react';
import classes from './floating_tabs.module.css';
export type FloatingTab = {
value: string;
label: string;
content: React.ReactNode;
disabled?: boolean;
indicator?: {
content: string;
color?: string;
pulse?: boolean;
disabled?: boolean;
};
};
interface FloatingTabsProps {
tabs: FloatingTab[];
keepMounted?: boolean;
}
export function FloatingTabs({ tabs, keepMounted = false }: FloatingTabsProps) {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [value, setValue] = useState<string | null>(tabs[0].value);
const [controlsRefs, setControlsRefs] = useState<
Record<string, HTMLButtonElement | null>
>({});
const setControlRef = (val: string) => (node: HTMLButtonElement) => {
controlsRefs[val] = node;
setControlsRefs(controlsRefs);
};
return (
<MantineTabs
variant="none"
value={value}
onChange={setValue}
keepMounted={keepMounted}
>
<MantineTabs.List ref={setRootRef} className={classes.list}>
{tabs.map((tab) => (
<Indicator
label={tab.indicator?.content}
color={tab.indicator?.color}
processing={tab.indicator?.pulse}
disabled={!tab.indicator || tab.indicator.disabled}
size={16}
key={tab.value}
>
<MantineTabs.Tab
value={tab.value}
ref={setControlRef(tab.value)}
className={classes.tab}
disabled={tab.disabled}
>
{tab.label}
</MantineTabs.Tab>
</Indicator>
))}
<FloatingIndicator
target={value ? controlsRefs[value] : null}
parent={rootRef}
className={classes.indicator}
/>
</MantineTabs.List>
{tabs.map((tab) => (
<MantineTabs.Panel key={tab.value} value={tab.value}>
<Stack>{tab.content}</Stack>
</MantineTabs.Panel>
))}
</MantineTabs>
);
}

View File

@@ -1,33 +1,38 @@
import { AUTHOR_GITHUB_URL, AUTHOR_NAME } from '#config/project';
import PATHS from '#core/constants/paths';
import { route } from '@izzyjs/route/client';
import { Anchor, Group, Text } from '@mantine/core';
import { withTranslation, WithTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { ExternalLinkStyled } from '~/components/common/links/external_link_styled';
import { InternalLink } from '~/components/common/links/internal_link';
import { LocaleSwitcher } from '~/components/common/locale_switcher';
import { ThemeSwitcher } from '~/components/common/theme_switcher';
import packageJson from '../../../../package.json';
import classes from './footer.module.css';
export const Footer = () => (
export const Footer = withTranslation()(({ t }: WithTranslation) => (
<Group className={classes.footer}>
<Group className={classes.footer__content}>
<Text>
Made with by{' '}
{t('footer.made_by')}{' '}
<ExternalLinkStyled href={AUTHOR_GITHUB_URL}>
{AUTHOR_NAME}
</ExternalLinkStyled>
</Text>
<Text>
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
{packageJson.version}
</Anchor>
</Text>
<Group gap="sm" mt={4} mb={4}>
<Group gap="sm">
<ThemeSwitcher />
<LocaleSwitcher />
</Group>
<Group gap="sm">
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
{packageJson.version}
</Anchor>
<InternalLink href={route('privacy').path}>{t('privacy')}</InternalLink>
<InternalLink href={route('terms').path}>{t('terms')}</InternalLink>
</Group>
</Group>
</Group>
);
));

View File

@@ -1,15 +1,10 @@
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 { LinkListSelector } from '~/components/dashboard/link/link_list_selector';
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();
@@ -28,15 +23,7 @@ export function UserPreferences() {
<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,
})
}
/>
<LinkListSelector />
</Stack>
</Fieldset>
);

View File

@@ -1,20 +1,28 @@
import { COLLECTION_LIST_DISPLAYS } from '#shared/lib/display_preferences';
import { CollectionListDisplay } from '#shared/types/index';
import { ComboList } from '~/components/common/combo_list/combo_list';
import { SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
import { getCollectionListDisplayOptions } from '~/lib/display_preferences';
export function CollectionListSelector() {
const { t } = useTranslation();
const { displayPreferences, handleUpdateDisplayPreferences } =
useDisplayPreferences();
const data = COLLECTION_LIST_DISPLAYS.map((display) => ({
label: t(`display-preferences.${display}`),
value: display,
}));
return (
<ComboList
selectedValue={displayPreferences.collectionListDisplay}
values={getCollectionListDisplayOptions()}
setValue={(value) =>
<SegmentedControl
data={data}
value={displayPreferences.collectionListDisplay}
onChange={(value) =>
handleUpdateDisplayPreferences({
collectionListDisplay: value as CollectionListDisplay,
})
}
w="50%"
/>
);
}

View File

@@ -0,0 +1,28 @@
import { LINK_LIST_DISPLAYS } from '#shared/lib/display_preferences';
import { LinkListDisplay } from '#shared/types/index';
import { SegmentedControl } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useDisplayPreferences } from '~/hooks/use_display_preferences';
export function LinkListSelector() {
const { t } = useTranslation();
const { displayPreferences, handleUpdateDisplayPreferences } =
useDisplayPreferences();
const data = LINK_LIST_DISPLAYS.map((display) => ({
label: t(`display-preferences.${display}`),
value: display,
}));
return (
<SegmentedControl
data={data}
value={displayPreferences.linkListDisplay}
onChange={(value) =>
handleUpdateDisplayPreferences({
linkListDisplay: value as LinkListDisplay,
})
}
w="50%"
/>
);
}

View File

@@ -1,19 +0,0 @@
.footer {
width: 100%;
}
.inner {
display: flex;
justify-content: space-between;
align-items: center;
@media (max-width: $mantine-breakpoint-xs) {
flex-direction: column;
}
}
.links {
@media (max-width: $mantine-breakpoint-xs) {
margin-top: var(--mantine-spacing-md);
}
}

View File

@@ -1,59 +0,0 @@
import PATHS from '#core/constants/paths';
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Anchor, Group, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import ExternalLink from '~/components/common/external_link';
import { LocaleSwitcher } from '~/components/common/locale_switcher';
import { ThemeSwitcher } from '~/components/common/theme_switcher';
import packageJson from '../../../package.json';
import classes from './footer.module.css';
export function MantineFooter() {
const { t } = useTranslation('common');
const links = [
{ link: route('privacy').path, label: t('privacy'), external: false },
{ link: route('terms').path, label: t('terms'), external: false },
{ link: PATHS.EXTENSION, label: 'Extension', external: true },
];
const items = links.map((link) => (
<Anchor
c="dimmed"
// @ts-expect-error
component={link.external ? ExternalLink : Link}
key={link.label}
href={link.link}
size="sm"
>
{link.label}
</Anchor>
));
return (
<div className={classes.footer}>
<div className={classes.inner}>
<Group gap={4} c="dimmed">
<Text size="sm">{t('footer.made_by')}</Text>{' '}
<Anchor size="sm" component={ExternalLink} href={PATHS.AUTHOR}>
Sonny
</Anchor>
{' • '}
<Anchor size="sm" component={ExternalLink} href={PATHS.REPO_GITHUB}>
{packageJson.version}
</Anchor>
</Group>
<Group gap="sm" mt={4} mb={4}>
<ThemeSwitcher />
<LocaleSwitcher />
</Group>
<Group gap="xs" justify="flex-end" wrap="nowrap">
{items}
</Group>
</div>
</div>
);
}

View File

@@ -89,7 +89,10 @@
"preferences-description": "Display preferences do not apply on mobile",
"display-preferences": {
"collection-list-display": "Collection list display",
"link-list-display": "Link list display"
"link-list-display": "Link list display",
"inline": "Inline",
"list": "List",
"grid": "Grid"
},
"coming-soon": "Under development"
}

View File

@@ -89,7 +89,10 @@
"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"
"link-list-display": "Affichage de la liste des liens",
"inline": "En ligne",
"list": "Liste",
"grid": "Grille"
},
"coming-soon": "En cours de développement"
}

View File

@@ -0,0 +1,45 @@
import { Box, rem } from '@mantine/core';
import { PropsWithChildren } from 'react';
import { FloatingNavbar } from '~/components/common/floating_navbar/floating_navbar';
import { Footer } from '~/components/common/footer/footer';
import { BaseLayout } from './_base_layout';
const SmallContentLayout = ({ children }: PropsWithChildren) => (
<BaseLayout>
<Layout>{children}</Layout>
</BaseLayout>
);
export default SmallContentLayout;
const LAYOUT_WIDTH = '1500px';
const CONTENT_WIDTH = '800px';
const Layout = ({ children }: PropsWithChildren) => (
<>
{/* Top navbar */}
<FloatingNavbar width={LAYOUT_WIDTH} />
{/* Page content */}
<Box
style={{
paddingInline: 'var(--mantine-spacing-lg)',
flex: 1,
}}
>
<Box
style={{
height: '100%',
maxWidth: '100%',
width: CONTENT_WIDTH,
marginInline: 'auto',
marginBlock: rem(30),
}}
>
{children}
</Box>
</Box>
{/* Footer */}
<Footer />
</>
);

View File

@@ -1,34 +0,0 @@
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],
}));
}

View File

@@ -1,6 +1,7 @@
import { useTranslation } from 'react-i18next';
import SmallContentLayout from '~/layouts/small_content';
export default function PrivacyPage() {
function PrivacyPage() {
const { t } = useTranslation('privacy');
return (
<>
@@ -41,3 +42,8 @@ export default function PrivacyPage() {
</>
);
}
PrivacyPage.layout = (page: React.ReactNode) => (
<SmallContentLayout>{page}</SmallContentLayout>
);
export default PrivacyPage;

View File

@@ -1,8 +1,9 @@
import { Link } from '@inertiajs/react';
import { route } from '@izzyjs/route/client';
import { Trans, useTranslation } from 'react-i18next';
import SmallContentLayout from '~/layouts/small_content';
export default function TermsPage() {
function TermsPage() {
const { t } = useTranslation('terms');
return (
<>
@@ -28,7 +29,7 @@ export default function TermsPage() {
<p>
<Trans
i18nKey="personal_data.collect.description"
components={{ a: <Link href={route('privacy').url} /> }}
components={{ a: <Link href={route('privacy').path} /> }}
/>
</p>
@@ -50,3 +51,8 @@ export default function TermsPage() {
</>
);
}
TermsPage.layout = (page: React.ReactNode) => (
<SmallContentLayout>{page}</SmallContentLayout>
);
export default TermsPage;

View File

@@ -0,0 +1,24 @@
import { useTranslation } from 'react-i18next';
import {
FloatingTab,
FloatingTabs,
} from '~/components/common/floating_tabs/floating_tabs';
import { UserPreferences } from '~/components/common/user_preferences/user_preferences';
import SmallContentLayout from '~/layouts/small_content';
function UserSettingsShow() {
const { t } = useTranslation();
const tabs: FloatingTab[] = [
{
label: t('preferences'),
value: 'preferences',
content: <UserPreferences />,
},
];
return <FloatingTabs tabs={tabs} />;
}
UserSettingsShow.layout = (page: React.ReactNode) => (
<SmallContentLayout>{page}</SmallContentLayout>
);
export default UserSettingsShow;

View File

@@ -31,6 +31,7 @@
"#search/*": "./app/search/*.js",
"#shared_collections/*": "./app/shared_collections/*.js",
"#user/*": "./app/user/*.js",
"#user_settings/*": "./app/user_settings/*.js",
"#providers/*": "./providers/*.js",
"#database/*": "./database/*.js",
"#tests/*": "./tests/*.js",

View File

@@ -8,3 +8,4 @@ import '#links/routes/routes';
import '#search/routes/routes';
import '#shared_collections/routes/routes';
import '#user/routes/routes';
import '#user_settings/routes/routes';