replace complex eslint config with biome

This commit is contained in:
Athou
2024-06-13 21:54:14 +02:00
parent 9115797dee
commit 3810dedf47
102 changed files with 7186 additions and 9488 deletions

View File

@@ -1,59 +1,59 @@
import { Trans } from "@lingui/macro"
import { Box, Button, Container, Group, Text, Title } from "@mantine/core"
import { TbRefresh } from "react-icons/tb"
import { tss } from "tss"
import { PageTitle } from "./PageTitle"
const useStyles = tss.create(({ theme }) => ({
root: {
paddingTop: 80,
},
label: {
textAlign: "center",
fontWeight: "bold",
fontSize: 120,
lineHeight: 1,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
color: theme.colors[theme.primaryColor][3],
},
title: {
textAlign: "center",
fontWeight: "bold",
fontSize: 32,
},
description: {
maxWidth: 540,
margin: "auto",
marginTop: theme.spacing.xl,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
},
}))
export function ErrorPage(props: { error: Error }) {
const { classes } = useStyles()
return (
<div className={classes.root}>
<Container>
<PageTitle />
<Box className={classes.label}>
<Trans>Oops!</Trans>
</Box>
<Title className={classes.title}>
<Trans>Something bad just happened...</Trans>
</Title>
<Text size="lg" ta="center" className={classes.description}>
{props.error.message}
</Text>
<Group justify="center">
<Button size="md" onClick={() => window.location.reload()} leftSection={<TbRefresh size={18} />}>
Refresh the page
</Button>
</Group>
</Container>
</div>
)
}
import { Trans } from "@lingui/macro"
import { Box, Button, Container, Group, Text, Title } from "@mantine/core"
import { TbRefresh } from "react-icons/tb"
import { tss } from "tss"
import { PageTitle } from "./PageTitle"
const useStyles = tss.create(({ theme }) => ({
root: {
paddingTop: 80,
},
label: {
textAlign: "center",
fontWeight: "bold",
fontSize: 120,
lineHeight: 1,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
color: theme.colors[theme.primaryColor][3],
},
title: {
textAlign: "center",
fontWeight: "bold",
fontSize: 32,
},
description: {
maxWidth: 540,
margin: "auto",
marginTop: theme.spacing.xl,
marginBottom: `calc(${theme.spacing.xl} * 1.5)`,
},
}))
export function ErrorPage(props: { error: Error }) {
const { classes } = useStyles()
return (
<div className={classes.root}>
<Container>
<PageTitle />
<Box className={classes.label}>
<Trans>Oops!</Trans>
</Box>
<Title className={classes.title}>
<Trans>Something bad just happened...</Trans>
</Title>
<Text size="lg" ta="center" className={classes.description}>
{props.error.message}
</Text>
<Group justify="center">
<Button size="md" onClick={() => window.location.reload()} leftSection={<TbRefresh size={18} />}>
Refresh the page
</Button>
</Group>
</Container>
</div>
)
}

View File

@@ -1,27 +1,27 @@
import { Center, Container, RingProgress, Text, useMantineTheme } from "@mantine/core"
import { useAppLoading } from "hooks/useAppLoading"
import { PageTitle } from "./PageTitle"
export function LoadingPage() {
const theme = useMantineTheme()
const { loadingPercentage, loadingStepLabel } = useAppLoading()
return (
<Container size="xs">
<PageTitle />
<Center>
<RingProgress
sections={[{ value: loadingPercentage, color: theme.primaryColor }]}
label={
<Text fw="bold" ta="center" size="xl">
{loadingPercentage}%
</Text>
}
/>
</Center>
{loadingStepLabel && <Center>{loadingStepLabel}</Center>}
</Container>
)
}
import { Center, Container, RingProgress, Text, useMantineTheme } from "@mantine/core"
import { useAppLoading } from "hooks/useAppLoading"
import { PageTitle } from "./PageTitle"
export function LoadingPage() {
const theme = useMantineTheme()
const { loadingPercentage, loadingStepLabel } = useAppLoading()
return (
<Container size="xs">
<PageTitle />
<Center>
<RingProgress
sections={[{ value: loadingPercentage, color: theme.primaryColor }]}
label={
<Text fw="bold" ta="center" size="xl">
{loadingPercentage}%
</Text>
}
/>
</Center>
{loadingStepLabel && <Center>{loadingStepLabel}</Center>}
</Container>
)
}

View File

@@ -1,13 +1,13 @@
import { Center, Title } from "@mantine/core"
import { Logo } from "components/Logo"
export function PageTitle() {
return (
<Center my="xl">
<Logo size={48} />
<Title order={1} ml="md">
CommaFeed
</Title>
</Center>
)
}
import { Center, Title } from "@mantine/core"
import { Logo } from "components/Logo"
export function PageTitle() {
return (
<Center my="xl">
<Logo size={48} />
<Title order={1} ml="md">
CommaFeed
</Title>
</Center>
)
}

View File

@@ -1,154 +1,154 @@
import { Trans } from "@lingui/macro"
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
import { client } from "app/client"
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import welcomePageDark from "assets/welcome_page_dark.png"
import welcomePageLight from "assets/welcome_page_light.png"
import { ActionButton } from "components/ActionButton"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
import { useAsyncCallback } from "react-async-hook"
import { SiGithub, SiTwitter } from "react-icons/si"
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
import { PageTitle } from "./PageTitle"
const iconSize = 18
export function WelcomePage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme } = useMantineColorScheme()
const dispatch = useAppDispatch()
const image = colorScheme === "light" ? welcomePageLight : welcomePageDark
const login = useAsyncCallback(client.user.login, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container>
<Header />
<Center my="lg">
<Title order={3}>Bloat-free feed reader</Title>
</Center>
{serverInfos?.demoAccountEnabled && (
<Center>
<ActionButton
label={<Trans>Try the demo!</Trans>}
icon={<TbClock size={iconSize} />}
variant="outline"
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
showLabelOnMobile
/>
</Center>
)}
<Divider my="lg" />
<Image src={image} />
<Divider my="lg" />
<Footer />
<Space h="lg" />
</Container>
)
}
function Header() {
const mobile = useMobile()
if (mobile) {
return (
<>
<PageTitle />
<Center>
<Buttons />
</Center>
</>
)
}
return (
<Group justify="space-between">
<Box>
<PageTitle />
</Box>
<Box>
<Buttons />
</Box>
</Group>
)
}
function Buttons() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme, toggleColorScheme } = useMantineColorScheme()
const { isBrowserExtensionPopup, openSettingsPage } = useBrowserExtension()
const dispatch = useAppDispatch()
const dark = colorScheme === "dark"
return (
<Group gap={14}>
<ActionButton
label={<Trans>Log in</Trans>}
icon={<TbKey size={iconSize} />}
variant="outline"
onClick={async () => await dispatch(redirectToLogin())}
showLabelOnMobile
/>
{serverInfos?.allowRegistrations && (
<ActionButton
label={<Trans>Sign up</Trans>}
icon={<TbUserPlus size={iconSize} />}
variant="filled"
onClick={async () => await dispatch(redirectToRegistration())}
showLabelOnMobile
/>
)}
<ActionButton
label={dark ? <Trans>Switch to light theme</Trans> : <Trans>Switch to dark theme</Trans>}
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
onClick={() => toggleColorScheme()}
hideLabelOnDesktop
/>
{isBrowserExtensionPopup && (
<ActionButton
label={<Trans>Extension options</Trans>}
icon={<TbSettings size={iconSize} />}
onClick={() => openSettingsPage()}
hideLabelOnDesktop
/>
)}
</Group>
)
}
function Footer() {
const dispatch = useAppDispatch()
return (
<Group justify="space-between">
<Group>
<span>© CommaFeed</span>
<Anchor variant="text" href="https://github.com/Athou/commafeed/" target="_blank" rel="noreferrer">
<SiGithub />
</Anchor>
<Anchor variant="text" href="https://twitter.com/CommaFeed" target="_blank" rel="noreferrer">
<SiTwitter />
</Anchor>
</Group>
<Box>
<Anchor variant="text" onClick={async () => await dispatch(redirectToApiDocumentation())}>
API documentation
</Anchor>
</Box>
</Group>
)
}
import { Trans } from "@lingui/macro"
import { Anchor, Box, Center, Container, Divider, Group, Image, Space, Title, useMantineColorScheme } from "@mantine/core"
import { client } from "app/client"
import { redirectToApiDocumentation, redirectToLogin, redirectToRegistration, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import welcomePageDark from "assets/welcome_page_dark.png"
import welcomePageLight from "assets/welcome_page_light.png"
import { ActionButton } from "components/ActionButton"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
import { useAsyncCallback } from "react-async-hook"
import { SiGithub, SiTwitter } from "react-icons/si"
import { TbClock, TbKey, TbMoon, TbSettings, TbSun, TbUserPlus } from "react-icons/tb"
import { PageTitle } from "./PageTitle"
const iconSize = 18
export function WelcomePage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme } = useMantineColorScheme()
const dispatch = useAppDispatch()
const image = colorScheme === "light" ? welcomePageLight : welcomePageDark
const login = useAsyncCallback(client.user.login, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container>
<Header />
<Center my="lg">
<Title order={3}>Bloat-free feed reader</Title>
</Center>
{serverInfos?.demoAccountEnabled && (
<Center>
<ActionButton
label={<Trans>Try the demo!</Trans>}
icon={<TbClock size={iconSize} />}
variant="outline"
onClick={async () => await login.execute({ name: "demo", password: "demo" })}
showLabelOnMobile
/>
</Center>
)}
<Divider my="lg" />
<Image src={image} />
<Divider my="lg" />
<Footer />
<Space h="lg" />
</Container>
)
}
function Header() {
const mobile = useMobile()
if (mobile) {
return (
<>
<PageTitle />
<Center>
<Buttons />
</Center>
</>
)
}
return (
<Group justify="space-between">
<Box>
<PageTitle />
</Box>
<Box>
<Buttons />
</Box>
</Group>
)
}
function Buttons() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const { colorScheme, toggleColorScheme } = useMantineColorScheme()
const { isBrowserExtensionPopup, openSettingsPage } = useBrowserExtension()
const dispatch = useAppDispatch()
const dark = colorScheme === "dark"
return (
<Group gap={14}>
<ActionButton
label={<Trans>Log in</Trans>}
icon={<TbKey size={iconSize} />}
variant="outline"
onClick={async () => await dispatch(redirectToLogin())}
showLabelOnMobile
/>
{serverInfos?.allowRegistrations && (
<ActionButton
label={<Trans>Sign up</Trans>}
icon={<TbUserPlus size={iconSize} />}
variant="filled"
onClick={async () => await dispatch(redirectToRegistration())}
showLabelOnMobile
/>
)}
<ActionButton
label={dark ? <Trans>Switch to light theme</Trans> : <Trans>Switch to dark theme</Trans>}
icon={colorScheme === "dark" ? <TbSun size={18} /> : <TbMoon size={iconSize} />}
onClick={() => toggleColorScheme()}
hideLabelOnDesktop
/>
{isBrowserExtensionPopup && (
<ActionButton
label={<Trans>Extension options</Trans>}
icon={<TbSettings size={iconSize} />}
onClick={() => openSettingsPage()}
hideLabelOnDesktop
/>
)}
</Group>
)
}
function Footer() {
const dispatch = useAppDispatch()
return (
<Group justify="space-between">
<Group>
<span>© CommaFeed</span>
<Anchor variant="text" href="https://github.com/Athou/commafeed/" target="_blank" rel="noreferrer">
<SiGithub />
</Anchor>
<Anchor variant="text" href="https://twitter.com/CommaFeed" target="_blank" rel="noreferrer">
<SiTwitter />
</Anchor>
</Group>
<Box>
<Anchor variant="text" onClick={async () => await dispatch(redirectToApiDocumentation())}>
API documentation
</Anchor>
</Box>
</Group>
)
}

View File

@@ -1,153 +1,153 @@
import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Code, Container, Group, Table, Text, Title, useMantineTheme } from "@mantine/core"
import { closeAllModals, openConfirmModal, openModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import { type UserModel } from "app/types"
import { UserEdit } from "components/admin/UserEdit"
import { Alert } from "components/Alert"
import { Loader } from "components/Loader"
import { RelativeDate } from "components/RelativeDate"
import { type ReactNode } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbCheck, TbPencil, TbPlus, TbTrash, TbX } from "react-icons/tb"
function BooleanIcon({ value }: { value: boolean }) {
return value ? <TbCheck size={18} /> : <TbX size={18} />
}
export function AdminUsersPage() {
const theme = useMantineTheme()
const query = useAsync(async () => await client.admin.getAllUsers(), [])
const users = query.result?.data.sort((a, b) => a.id - b.id)
const deleteUser = useAsyncCallback(client.admin.deleteUser, {
onSuccess: () => {
query.execute()
closeAllModals()
},
})
const openUserEditModal = (title: ReactNode, user?: UserModel) => {
openModal({
title,
children: (
<UserEdit
user={user}
onCancel={closeAllModals}
onSave={() => {
query.execute()
closeAllModals()
}}
/>
),
})
}
const openUserDeleteModal = (user: UserModel) => {
const userName = user.name
openConfirmModal({
title: <Trans>Delete user</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to delete user <Code>{userName}</Code> ?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await deleteUser.execute({ id: user.id }),
})
}
if (!users) return <Loader />
return (
<Container>
<Title order={3} mb="md">
<Group>
<Trans>Manage users</Trans>
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
<TbPlus size={20} />
</ActionIcon>
</Group>
</Title>
{deleteUser.error && (
<Box mb="md">
<Alert messages={errorToStrings(deleteUser.error)} />
</Box>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Id</Trans>
</Table.Th>
<Table.Th>
<Trans>Name</Trans>
</Table.Th>
<Table.Th>
<Trans>E-mail</Trans>
</Table.Th>
<Table.Th>
<Trans>Date created</Trans>
</Table.Th>
<Table.Th>
<Trans>Last login date</Trans>
</Table.Th>
<Table.Th>
<Trans>Admin</Trans>
</Table.Th>
<Table.Th>
<Trans>Enabled</Trans>
</Table.Th>
<Table.Th>
<Trans>Actions</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map(u => (
<Table.Tr key={u.id}>
<Table.Td>{u.id}</Table.Td>
<Table.Td>{u.name}</Table.Td>
<Table.Td>{u.email}</Table.Td>
<Table.Td>
<RelativeDate date={u.created} />
</Table.Td>
<Table.Td>
<RelativeDate date={u.lastLogin} />
</Table.Td>
<Table.Td>
<BooleanIcon value={u.admin} />
</Table.Td>
<Table.Td>
<BooleanIcon value={u.enabled} />
</Table.Td>
<Table.Td>
<Group>
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserEditModal(<Trans>Edit user</Trans>, u)}
>
<TbPencil size={18} />
</ActionIcon>
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserDeleteModal(u)}
loading={deleteUser.loading}
>
<TbTrash size={18} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Code, Container, Group, Table, Text, Title, useMantineTheme } from "@mantine/core"
import { closeAllModals, openConfirmModal, openModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import type { UserModel } from "app/types"
import { Alert } from "components/Alert"
import { Loader } from "components/Loader"
import { RelativeDate } from "components/RelativeDate"
import { UserEdit } from "components/admin/UserEdit"
import type { ReactNode } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbCheck, TbPencil, TbPlus, TbTrash, TbX } from "react-icons/tb"
function BooleanIcon({ value }: { value: boolean }) {
return value ? <TbCheck size={18} /> : <TbX size={18} />
}
export function AdminUsersPage() {
const theme = useMantineTheme()
const query = useAsync(async () => await client.admin.getAllUsers(), [])
const users = query.result?.data.sort((a, b) => a.id - b.id)
const deleteUser = useAsyncCallback(client.admin.deleteUser, {
onSuccess: () => {
query.execute()
closeAllModals()
},
})
const openUserEditModal = (title: ReactNode, user?: UserModel) => {
openModal({
title,
children: (
<UserEdit
user={user}
onCancel={closeAllModals}
onSave={() => {
query.execute()
closeAllModals()
}}
/>
),
})
}
const openUserDeleteModal = (user: UserModel) => {
const userName = user.name
openConfirmModal({
title: <Trans>Delete user</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to delete user <Code>{userName}</Code> ?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await deleteUser.execute({ id: user.id }),
})
}
if (!users) return <Loader />
return (
<Container>
<Title order={3} mb="md">
<Group>
<Trans>Manage users</Trans>
<ActionIcon color={theme.primaryColor} variant="subtle" onClick={() => openUserEditModal(<Trans>Add user</Trans>)}>
<TbPlus size={20} />
</ActionIcon>
</Group>
</Title>
{deleteUser.error && (
<Box mb="md">
<Alert messages={errorToStrings(deleteUser.error)} />
</Box>
)}
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>
<Trans>Id</Trans>
</Table.Th>
<Table.Th>
<Trans>Name</Trans>
</Table.Th>
<Table.Th>
<Trans>E-mail</Trans>
</Table.Th>
<Table.Th>
<Trans>Date created</Trans>
</Table.Th>
<Table.Th>
<Trans>Last login date</Trans>
</Table.Th>
<Table.Th>
<Trans>Admin</Trans>
</Table.Th>
<Table.Th>
<Trans>Enabled</Trans>
</Table.Th>
<Table.Th>
<Trans>Actions</Trans>
</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{users.map(u => (
<Table.Tr key={u.id}>
<Table.Td>{u.id}</Table.Td>
<Table.Td>{u.name}</Table.Td>
<Table.Td>{u.email}</Table.Td>
<Table.Td>
<RelativeDate date={u.created} />
</Table.Td>
<Table.Td>
<RelativeDate date={u.lastLogin} />
</Table.Td>
<Table.Td>
<BooleanIcon value={u.admin} />
</Table.Td>
<Table.Td>
<BooleanIcon value={u.enabled} />
</Table.Td>
<Table.Td>
<Group>
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserEditModal(<Trans>Edit user</Trans>, u)}
>
<TbPencil size={18} />
</ActionIcon>
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={() => openUserDeleteModal(u)}
loading={deleteUser.loading}
>
<TbTrash size={18} />
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Container>
)
}

View File

@@ -1,74 +1,74 @@
import { Accordion, Box, Tabs } from "@mantine/core"
import { client } from "app/client"
import { Loader } from "components/Loader"
import { Gauge } from "components/metrics/Gauge"
import { Meter } from "components/metrics/Meter"
import { MetricAccordionItem } from "components/metrics/MetricAccordionItem"
import { Timer } from "components/metrics/Timer"
import { useAsync } from "react-async-hook"
import { TbChartAreaLine, TbClock } from "react-icons/tb"
const shownMeters: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.refill": "Feed queue refill rate",
"com.commafeed.backend.feed.FeedRefreshWorker.feedFetched": "Feed fetching rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.feedUpdated": "Feed update rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheHit": "Entry cache hit rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheMiss": "Entry cache miss rate",
"com.commafeed.backend.service.db.DatabaseCleaningService.entriesDeleted": "Entries deleted",
}
const shownGauges: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Queue size",
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Worker active",
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Updater active",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
"com.commafeed.frontend.ws.WebSocketSessions.sessions": "WebSocket sessions",
}
export function MetricsPage() {
const query = useAsync(async () => await client.admin.getMetrics(), [])
if (!query.result) return <Loader />
const { meters, gauges, timers } = query.result.data
return (
<Tabs defaultValue="stats">
<Tabs.List>
<Tabs.Tab value="stats" leftSection={<TbChartAreaLine size={14} />}>
Stats
</Tabs.Tab>
<Tabs.Tab value="timers" leftSection={<TbClock size={14} />}>
Timers
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="xs">
<Accordion variant="contained" chevronPosition="left">
{Object.keys(shownMeters).map(m => (
<MetricAccordionItem key={m} metricKey={m} name={shownMeters[m]} headerValue={meters[m].count}>
<Meter meter={meters[m]} />
</MetricAccordionItem>
))}
</Accordion>
<Box pt="xs">
{Object.keys(shownGauges).map(g => (
<Box key={g}>
<span>{shownGauges[g]}&nbsp;</span>
<Gauge gauge={gauges[g]} />
</Box>
))}
</Box>
</Tabs.Panel>
<Tabs.Panel value="timers" pt="xs">
<Accordion variant="contained" chevronPosition="left">
{Object.keys(timers).map(key => (
<MetricAccordionItem key={key} metricKey={key} name={key} headerValue={timers[key].count}>
<Timer timer={timers[key]} />
</MetricAccordionItem>
))}
</Accordion>
</Tabs.Panel>
</Tabs>
)
}
import { Accordion, Box, Tabs } from "@mantine/core"
import { client } from "app/client"
import { Loader } from "components/Loader"
import { Gauge } from "components/metrics/Gauge"
import { Meter } from "components/metrics/Meter"
import { MetricAccordionItem } from "components/metrics/MetricAccordionItem"
import { Timer } from "components/metrics/Timer"
import { useAsync } from "react-async-hook"
import { TbChartAreaLine, TbClock } from "react-icons/tb"
const shownMeters: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.refill": "Feed queue refill rate",
"com.commafeed.backend.feed.FeedRefreshWorker.feedFetched": "Feed fetching rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.feedUpdated": "Feed update rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheHit": "Entry cache hit rate",
"com.commafeed.backend.feed.FeedRefreshUpdater.entryCacheMiss": "Entry cache miss rate",
"com.commafeed.backend.service.db.DatabaseCleaningService.entriesDeleted": "Entries deleted",
}
const shownGauges: Record<string, string> = {
"com.commafeed.backend.feed.FeedRefreshEngine.queue.size": "Queue size",
"com.commafeed.backend.feed.FeedRefreshEngine.worker.active": "Feed Worker active",
"com.commafeed.backend.feed.FeedRefreshEngine.updater.active": "Feed Updater active",
"com.commafeed.frontend.ws.WebSocketSessions.users": "WebSocket users",
"com.commafeed.frontend.ws.WebSocketSessions.sessions": "WebSocket sessions",
}
export function MetricsPage() {
const query = useAsync(async () => await client.admin.getMetrics(), [])
if (!query.result) return <Loader />
const { meters, gauges, timers } = query.result.data
return (
<Tabs defaultValue="stats">
<Tabs.List>
<Tabs.Tab value="stats" leftSection={<TbChartAreaLine size={14} />}>
Stats
</Tabs.Tab>
<Tabs.Tab value="timers" leftSection={<TbClock size={14} />}>
Timers
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="stats" pt="xs">
<Accordion variant="contained" chevronPosition="left">
{Object.keys(shownMeters).map(m => (
<MetricAccordionItem key={m} metricKey={m} name={shownMeters[m]} headerValue={meters[m].count}>
<Meter meter={meters[m]} />
</MetricAccordionItem>
))}
</Accordion>
<Box pt="xs">
{Object.keys(shownGauges).map(g => (
<Box key={g}>
<span>{shownGauges[g]}&nbsp;</span>
<Gauge gauge={gauges[g]} />
</Box>
))}
</Box>
</Tabs.Panel>
<Tabs.Panel value="timers" pt="xs">
<Accordion variant="contained" chevronPosition="left">
{Object.keys(timers).map(key => (
<MetricAccordionItem key={key} metricKey={key} name={key} headerValue={timers[key].count}>
<Timer timer={timers[key]} />
</MetricAccordionItem>
))}
</Accordion>
</Tabs.Panel>
</Tabs>
)
}

View File

@@ -1,129 +1,130 @@
import { t, Trans } from "@lingui/macro"
import { Anchor, Box, Container, List, NativeSelect, SimpleGrid, Title } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToApiDocumentation } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { CategorySelect } from "components/content/add/CategorySelect"
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import React, { useState } from "react"
import { TbHelp, TbKeyboard, TbPuzzle, TbRocket } from "react-icons/tb"
import { tss } from "tss"
const useStyles = tss.create(() => ({
sectionTitle: {
display: "flex",
alignItems: "center",
},
}))
function Section(props: { title: React.ReactNode; icon: React.ReactNode; children: React.ReactNode }) {
const { classes } = useStyles()
return (
<Box my="xl">
<Box className={classes.sectionTitle} mb="xs">
{props.icon}
<Title order={3} ml="xs">
{props.title}
</Title>
</Box>
<Box>{props.children}</Box>
</Box>
)
}
function NextUnreadBookmarklet() {
const [categoryId, setCategoryId] = useState(Constants.categories.all.id)
const [order, setOrder] = useState("desc")
const baseUrl = window.location.href.substring(0, window.location.href.lastIndexOf("#"))
const href = `javascript:window.location.href='${baseUrl}next?category=${categoryId}&order=${order}&t='+new Date().getTime();`
return (
<Box>
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
<NativeSelect
data={[
{ value: "desc", label: t`Newest first` },
{ value: "asc", label: t`Oldest first` },
]}
value={order}
onChange={e => setOrder(e.target.value)}
description={<Trans>Order</Trans>}
/>
<Trans>Drag link to bookmark bar</Trans>
<span> </span>
<Anchor href={href} target="_blank" rel="noreferrer">
<Trans>CommaFeed next unread item</Trans>
</Anchor>
</Box>
)
}
export function AboutPage() {
const version = useAppSelector(state => state.server.serverInfos?.version)
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
const dispatch = useAppDispatch()
return (
<Container size="xl">
<SimpleGrid cols={{ base: 1, [Constants.layout.mobileBreakpointName]: 2 }}>
<Section title={<Trans>About</Trans>} icon={<TbHelp size={24} />}>
<Box>
<Trans>
CommaFeed version {version} ({revision}).
</Trans>
</Box>
{isBrowserExtensionInstallable && isBrowserExtensionInstalled && (
<Box>
<Trans>CommaFeed browser extension version {browserExtensionVersion}.</Trans>
</Box>
)}
<Box mt="md">
<Trans>
<span>CommaFeed is an open-source project. Sources are hosted on </span>
<Anchor href="https://github.com/Athou/commafeed" target="_blank" rel="noreferrer">
GitHub
</Anchor>
.
</Trans>
</Box>
<Box>
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
</Box>
</Section>
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
<List>
<List.Item>
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
<Trans>Browser extention</Trans>
</Anchor>
</List.Item>
<List.Item>
<Trans>Subscribe URL</Trans>
<span> </span>
<Anchor href="rest/feed/subscribe?url=FEED_URL_HERE" target="_blank" rel="noreferrer">
rest/feed/subscribe?url=FEED_URL_HERE
</Anchor>
</List.Item>
<List.Item>
<Trans>Next unread item bookmarklet</Trans>
<span> </span>
<Box ml="xl">
<NextUnreadBookmarklet />
</Box>
</List.Item>
</List>
</Section>
<Section title={<Trans>Keyboard shortcuts</Trans>} icon={<TbKeyboard size={24} />}>
<KeyboardShortcutsHelp />
</Section>
<Section title={<Trans>REST API</Trans>} icon={<TbRocket size={24} />}>
<Anchor onClick={async () => await dispatch(redirectToApiDocumentation())}>
<Trans>Go to the API documentation.</Trans>
</Anchor>
</Section>
</SimpleGrid>
</Container>
)
}
import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Container, List, NativeSelect, SimpleGrid, Title } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToApiDocumentation } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { KeyboardShortcutsHelp } from "components/KeyboardShortcutsHelp"
import { CategorySelect } from "components/content/add/CategorySelect"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import type React from "react"
import { useState } from "react"
import { TbHelp, TbKeyboard, TbPuzzle, TbRocket } from "react-icons/tb"
import { tss } from "tss"
const useStyles = tss.create(() => ({
sectionTitle: {
display: "flex",
alignItems: "center",
},
}))
function Section(props: { title: React.ReactNode; icon: React.ReactNode; children: React.ReactNode }) {
const { classes } = useStyles()
return (
<Box my="xl">
<Box className={classes.sectionTitle} mb="xs">
{props.icon}
<Title order={3} ml="xs">
{props.title}
</Title>
</Box>
<Box>{props.children}</Box>
</Box>
)
}
function NextUnreadBookmarklet() {
const [categoryId, setCategoryId] = useState(Constants.categories.all.id)
const [order, setOrder] = useState("desc")
const baseUrl = window.location.href.substring(0, window.location.href.lastIndexOf("#"))
const href = `javascript:window.location.href='${baseUrl}next?category=${categoryId}&order=${order}&t='+new Date().getTime();`
return (
<Box>
<CategorySelect value={categoryId} onChange={c => c && setCategoryId(c)} withAll description={<Trans>Category</Trans>} />
<NativeSelect
data={[
{ value: "desc", label: t`Newest first` },
{ value: "asc", label: t`Oldest first` },
]}
value={order}
onChange={e => setOrder(e.target.value)}
description={<Trans>Order</Trans>}
/>
<Trans>Drag link to bookmark bar</Trans>
<span> </span>
<Anchor href={href} target="_blank" rel="noreferrer">
<Trans>CommaFeed next unread item</Trans>
</Anchor>
</Box>
)
}
export function AboutPage() {
const version = useAppSelector(state => state.server.serverInfos?.version)
const revision = useAppSelector(state => state.server.serverInfos?.gitCommit)
const { isBrowserExtensionInstalled, browserExtensionVersion, isBrowserExtensionInstallable } = useBrowserExtension()
const dispatch = useAppDispatch()
return (
<Container size="xl">
<SimpleGrid cols={{ base: 1, [Constants.layout.mobileBreakpointName]: 2 }}>
<Section title={<Trans>About</Trans>} icon={<TbHelp size={24} />}>
<Box>
<Trans>
CommaFeed version {version} ({revision}).
</Trans>
</Box>
{isBrowserExtensionInstallable && isBrowserExtensionInstalled && (
<Box>
<Trans>CommaFeed browser extension version {browserExtensionVersion}.</Trans>
</Box>
)}
<Box mt="md">
<Trans>
<span>CommaFeed is an open-source project. Sources are hosted on </span>
<Anchor href="https://github.com/Athou/commafeed" target="_blank" rel="noreferrer">
GitHub
</Anchor>
.
</Trans>
</Box>
<Box>
<Trans>If you encounter an issue, please report it on the issues page of the GitHub project.</Trans>
</Box>
</Section>
<Section title={<Trans>Goodies</Trans>} icon={<TbPuzzle size={24} />}>
<List>
<List.Item>
<Anchor href={Constants.browserExtensionUrl} target="_blank" rel="noreferrer">
<Trans>Browser extention</Trans>
</Anchor>
</List.Item>
<List.Item>
<Trans>Subscribe URL</Trans>
<span> </span>
<Anchor href="rest/feed/subscribe?url=FEED_URL_HERE" target="_blank" rel="noreferrer">
rest/feed/subscribe?url=FEED_URL_HERE
</Anchor>
</List.Item>
<List.Item>
<Trans>Next unread item bookmarklet</Trans>
<span> </span>
<Box ml="xl">
<NextUnreadBookmarklet />
</Box>
</List.Item>
</List>
</Section>
<Section title={<Trans>Keyboard shortcuts</Trans>} icon={<TbKeyboard size={24} />}>
<KeyboardShortcutsHelp />
</Section>
<Section title={<Trans>REST API</Trans>} icon={<TbRocket size={24} />}>
<Anchor onClick={async () => await dispatch(redirectToApiDocumentation())}>
<Trans>Go to the API documentation.</Trans>
</Anchor>
</Section>
</SimpleGrid>
</Container>
)
}

View File

@@ -1,38 +1,38 @@
import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core"
import { AddCategory } from "components/content/add/AddCategory"
import { ImportOpml } from "components/content/add/ImportOpml"
import { Subscribe } from "components/content/add/Subscribe"
import { TbFileImport, TbFolderPlus, TbRss } from "react-icons/tb"
export function AddPage() {
return (
<Container size="sm" px={0}>
<Tabs defaultValue="subscribe">
<Tabs.List>
<Tabs.Tab value="subscribe" leftSection={<TbRss size={16} />}>
<Trans>Subscribe</Trans>
</Tabs.Tab>
<Tabs.Tab value="category" leftSection={<TbFolderPlus size={16} />}>
<Trans>Add category</Trans>
</Tabs.Tab>
<Tabs.Tab value="opml" leftSection={<TbFileImport size={16} />}>
<Trans>OPML</Trans>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="subscribe" pt="xl">
<Subscribe />
</Tabs.Panel>
<Tabs.Panel value="category" pt="xl">
<AddCategory />
</Tabs.Panel>
<Tabs.Panel value="opml" pt="xl">
<ImportOpml />
</Tabs.Panel>
</Tabs>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core"
import { AddCategory } from "components/content/add/AddCategory"
import { ImportOpml } from "components/content/add/ImportOpml"
import { Subscribe } from "components/content/add/Subscribe"
import { TbFileImport, TbFolderPlus, TbRss } from "react-icons/tb"
export function AddPage() {
return (
<Container size="sm" px={0}>
<Tabs defaultValue="subscribe">
<Tabs.List>
<Tabs.Tab value="subscribe" leftSection={<TbRss size={16} />}>
<Trans>Subscribe</Trans>
</Tabs.Tab>
<Tabs.Tab value="category" leftSection={<TbFolderPlus size={16} />}>
<Trans>Add category</Trans>
</Tabs.Tab>
<Tabs.Tab value="opml" leftSection={<TbFileImport size={16} />}>
<Trans>OPML</Trans>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="subscribe" pt="xl">
<Subscribe />
</Tabs.Panel>
<Tabs.Panel value="category" pt="xl">
<AddCategory />
</Tabs.Panel>
<Tabs.Panel value="opml" pt="xl">
<ImportOpml />
</Tabs.Panel>
</Tabs>
</Container>
)
}

View File

@@ -1,20 +1,20 @@
import { Box } from "@mantine/core"
import { HistoryService, RedocStandalone } from "redoc"
// disable redoc url sync because it causes issues with hashrouter
Object.defineProperty(HistoryService.prototype, "replace", {
value: () => {
// do nothing
},
})
function ApiDocumentationPage() {
return (
// force white background because documentation does not support dark theme
<Box style={{ backgroundColor: "#fff" }}>
<RedocStandalone specUrl="openapi.json" />
</Box>
)
}
export default ApiDocumentationPage
import { Box } from "@mantine/core"
import { HistoryService, RedocStandalone } from "redoc"
// disable redoc url sync because it causes issues with hashrouter
Object.defineProperty(HistoryService.prototype, "replace", {
value: () => {
// do nothing
},
})
function ApiDocumentationPage() {
return (
// force white background because documentation does not support dark theme
<Box style={{ backgroundColor: "#fff" }}>
<RedocStandalone specUrl="openapi.json" />
</Box>
)
}
export default ApiDocumentationPage

View File

@@ -1,149 +1,149 @@
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import { Constants } from "app/constants"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks"
import { type CategoryModificationRequest } from "app/types"
import { flattenCategoryTree } from "app/utils"
import { Alert } from "components/Alert"
import { CategorySelect } from "components/content/add/CategorySelect"
import { Loader } from "components/Loader"
import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom"
export function CategoryDetailsPage() {
const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
const query = useAsync(async () => await client.category.getRoot(), [])
const category =
id === Constants.categories.starred.id
? Constants.categories.starred
: query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
const form = useForm<CategoryModificationRequest>()
const { setValues } = form
const modifyCategory = useAsyncCallback(client.category.modify, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToSelectedSource())
},
})
const deleteCategory = useAsyncCallback(client.category.delete, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToRootCategory())
},
})
const openDeleteCategoryModal = () => {
const categoryName = category?.name
openConfirmModal({
title: <Trans>Delete Category</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to delete category <Code>{categoryName}</Code>?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await deleteCategory.execute({ id: +id }),
})
}
useEffect(() => {
if (!category) return
setValues({
id: +category.id,
name: category.name,
parentId: category.parentId,
position: category.position,
})
}, [setValues, category])
const editable = id !== Constants.categories.all.id && id !== Constants.categories.starred.id
if (!category) return <Loader />
return (
<Container>
{modifyCategory.error && (
<Box mb="md">
<Alert messages={errorToStrings(modifyCategory.error)} />
</Box>
)}
{deleteCategory.error && (
<Box mb="md">
<Alert messages={errorToStrings(deleteCategory.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(modifyCategory.execute)}>
<Stack>
<Title order={3}>{category.name}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor
href={`rest/category/entriesAsFeed?id=${category.id}&apiKey=${apiKey}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
{editable && (
<>
<Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect
label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")}
clearable
withoutCategoryIds={[id]}
/>
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</>
)}
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
{editable && (
<>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
<Trans>Save</Trans>
</Button>
<Divider orientation="vertical" />
<Button
color="red"
leftSection={<TbTrash size={16} />}
onClick={() => openDeleteCategoryModal()}
loading={deleteCategory.loading}
>
<Trans>Delete</Trans>
</Button>
</>
)}
</Group>
</Stack>
</form>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import { Constants } from "app/constants"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks"
import type { CategoryModificationRequest } from "app/types"
import { flattenCategoryTree } from "app/utils"
import { Alert } from "components/Alert"
import { Loader } from "components/Loader"
import { CategorySelect } from "components/content/add/CategorySelect"
import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom"
export function CategoryDetailsPage() {
const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
const query = useAsync(async () => await client.category.getRoot(), [])
const category =
id === Constants.categories.starred.id
? Constants.categories.starred
: query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
const form = useForm<CategoryModificationRequest>()
const { setValues } = form
const modifyCategory = useAsyncCallback(client.category.modify, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToSelectedSource())
},
})
const deleteCategory = useAsyncCallback(client.category.delete, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToRootCategory())
},
})
const openDeleteCategoryModal = () => {
const categoryName = category?.name
openConfirmModal({
title: <Trans>Delete Category</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to delete category <Code>{categoryName}</Code>?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await deleteCategory.execute({ id: +id }),
})
}
useEffect(() => {
if (!category) return
setValues({
id: +category.id,
name: category.name,
parentId: category.parentId,
position: category.position,
})
}, [setValues, category])
const editable = id !== Constants.categories.all.id && id !== Constants.categories.starred.id
if (!category) return <Loader />
return (
<Container>
{modifyCategory.error && (
<Box mb="md">
<Alert messages={errorToStrings(modifyCategory.error)} />
</Box>
)}
{deleteCategory.error && (
<Box mb="md">
<Alert messages={errorToStrings(deleteCategory.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(modifyCategory.execute)}>
<Stack>
<Title order={3}>{category.name}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor
href={`rest/category/entriesAsFeed?id=${category.id}&apiKey=${apiKey}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
{editable && (
<>
<Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect
label={<Trans>Parent Category</Trans>}
{...form.getInputProps("parentId")}
clearable
withoutCategoryIds={[id]}
/>
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
</>
)}
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
{editable && (
<>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyCategory.loading}>
<Trans>Save</Trans>
</Button>
<Divider orientation="vertical" />
<Button
color="red"
leftSection={<TbTrash size={16} />}
onClick={() => openDeleteCategoryModal()}
loading={deleteCategory.loading}
>
<Trans>Delete</Trans>
</Button>
</>
)}
</Group>
</Stack>
</form>
</Container>
)
}

View File

@@ -1,186 +1,186 @@
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks"
import { type FeedModificationRequest } from "app/types"
import { Alert } from "components/Alert"
import { CategorySelect } from "components/content/add/CategorySelect"
import { Loader } from "components/Loader"
import { RelativeDate } from "components/RelativeDate"
import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom"
function FilteringExpressionDescription() {
const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code>
return (
<div>
<div>
<Trans>
If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read
automatically.
</Trans>
</div>
<div>
<Trans>
Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case
to ease string comparison.
</Trans>
</div>
<div>
<Trans>Example: {example}.</Trans>
</div>
<div>
<Trans>
<span>Complete syntax is available </span>
<a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer">
here
</a>
.
</Trans>
</div>
</div>
)
}
export function FeedDetailsPage() {
const { id } = useParams()
if (!id) throw Error("id required")
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
const query = useAsync(async () => await client.feed.get(id), [id])
const feed = query.result?.data
const form = useForm<FeedModificationRequest>()
const { setValues } = form
const modifyFeed = useAsyncCallback(client.feed.modify, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToSelectedSource())
},
})
const unsubscribe = useAsyncCallback(client.feed.unsubscribe, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToRootCategory())
},
})
const openUnsubscribeModal = () => {
const feedName = feed?.name
openConfirmModal({
title: <Trans>Unsubscribe</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to unsubscribe from <Code>{feedName}</Code>?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await unsubscribe.execute({ id: +id }),
})
}
useEffect(() => {
if (!feed) return
setValues(feed)
}, [setValues, feed])
if (!feed) return <Loader />
return (
<Container>
{modifyFeed.error && (
<Box mb="md">
<Alert messages={errorToStrings(modifyFeed.error)} />
</Box>
)}
{unsubscribe.error && (
<Box mb="md">
<Alert messages={errorToStrings(unsubscribe.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(modifyFeed.execute)}>
<Stack>
<Title order={3}>{feed.name}</Title>
<Input.Wrapper label={<Trans>Feed URL</Trans>}>
<Box>
<Anchor href={feed.feedUrl} target="_blank" rel="noreferrer">
{feed.feedUrl}
</Anchor>
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Website</Trans>}>
<Box>
<Anchor href={feed.feedLink} target="_blank" rel="noreferrer">
{feed.feedLink}
</Anchor>
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh</Trans>}>
<Box>
<RelativeDate date={feed.lastRefresh} />
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh message</Trans>}>
<Box>{feed.message ?? <Trans>N/A</Trans>}</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Next refresh</Trans>}>
<Box>
<RelativeDate date={feed.nextRefresh} />
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
<Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
<TextInput
label={<Trans>Filtering expression</Trans>}
description={<FilteringExpressionDescription />}
{...form.getInputProps("filter")}
/>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyFeed.loading}>
<Trans>Save</Trans>
</Button>
<Divider orientation="vertical" />
<Button
color="red"
leftSection={<TbTrash size={16} />}
onClick={() => openUnsubscribeModal()}
loading={unsubscribe.loading}
>
<Trans>Unsubscribe</Trans>
</Button>
</Group>
</Stack>
</form>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Code, Container, Divider, Group, Input, NumberInput, Stack, Text, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { openConfirmModal } from "@mantine/modals"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory, redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { reloadTree } from "app/tree/thunks"
import type { FeedModificationRequest } from "app/types"
import { Alert } from "components/Alert"
import { Loader } from "components/Loader"
import { RelativeDate } from "components/RelativeDate"
import { CategorySelect } from "components/content/add/CategorySelect"
import { useEffect } from "react"
import { useAsync, useAsyncCallback } from "react-async-hook"
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
import { useParams } from "react-router-dom"
function FilteringExpressionDescription() {
const example = <Code>url.contains('youtube') or (author eq 'athou' and title.contains('github'))</Code>
return (
<div>
<div>
<Trans>
If not empty, an expression evaluating to 'true' or 'false'. If false, new entries for this feed will be marked as read
automatically.
</Trans>
</div>
<div>
<Trans>
Available variables are 'title', 'content', 'url' 'author' and 'categories' and their content is converted to lower case
to ease string comparison.
</Trans>
</div>
<div>
<Trans>Example: {example}.</Trans>
</div>
<div>
<Trans>
<span>Complete syntax is available </span>
<a href="https://commons.apache.org/proper/commons-jexl/reference/syntax.html" target="_blank" rel="noreferrer">
here
</a>
.
</Trans>
</div>
</div>
)
}
export function FeedDetailsPage() {
const { id } = useParams()
if (!id) throw Error("id required")
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
const query = useAsync(async () => await client.feed.get(id), [id])
const feed = query.result?.data
const form = useForm<FeedModificationRequest>()
const { setValues } = form
const modifyFeed = useAsyncCallback(client.feed.modify, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToSelectedSource())
},
})
const unsubscribe = useAsyncCallback(client.feed.unsubscribe, {
onSuccess: () => {
dispatch(reloadTree())
dispatch(redirectToRootCategory())
},
})
const openUnsubscribeModal = () => {
const feedName = feed?.name
openConfirmModal({
title: <Trans>Unsubscribe</Trans>,
children: (
<Text size="sm">
<Trans>
Are you sure you want to unsubscribe from <Code>{feedName}</Code>?
</Trans>
</Text>
),
labels: { confirm: <Trans>Confirm</Trans>, cancel: <Trans>Cancel</Trans> },
confirmProps: { color: "red" },
onConfirm: async () => await unsubscribe.execute({ id: +id }),
})
}
useEffect(() => {
if (!feed) return
setValues(feed)
}, [setValues, feed])
if (!feed) return <Loader />
return (
<Container>
{modifyFeed.error && (
<Box mb="md">
<Alert messages={errorToStrings(modifyFeed.error)} />
</Box>
)}
{unsubscribe.error && (
<Box mb="md">
<Alert messages={errorToStrings(unsubscribe.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(modifyFeed.execute)}>
<Stack>
<Title order={3}>{feed.name}</Title>
<Input.Wrapper label={<Trans>Feed URL</Trans>}>
<Box>
<Anchor href={feed.feedUrl} target="_blank" rel="noreferrer">
{feed.feedUrl}
</Anchor>
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Website</Trans>}>
<Box>
<Anchor href={feed.feedLink} target="_blank" rel="noreferrer">
{feed.feedLink}
</Anchor>
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh</Trans>}>
<Box>
<RelativeDate date={feed.lastRefresh} />
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Last refresh message</Trans>}>
<Box>{feed.message ?? <Trans>N/A</Trans>}</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Next refresh</Trans>}>
<Box>
<RelativeDate date={feed.nextRefresh} />
</Box>
</Input.Wrapper>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor href={`rest/feed/entriesAsFeed?id=${feed.id}&apiKey=${apiKey}`} target="_blank" rel="noreferrer">
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
<Divider />
<TextInput label={<Trans>Name</Trans>} {...form.getInputProps("name")} required />
<CategorySelect label={<Trans>Category</Trans>} {...form.getInputProps("categoryId")} clearable />
<NumberInput label={<Trans>Position</Trans>} {...form.getInputProps("position")} required min={0} />
<TextInput
label={<Trans>Filtering expression</Trans>}
description={<FilteringExpressionDescription />}
{...form.getInputProps("filter")}
/>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
<Button type="submit" leftSection={<TbDeviceFloppy size={16} />} loading={modifyFeed.loading}>
<Trans>Save</Trans>
</Button>
<Divider orientation="vertical" />
<Button
color="red"
leftSection={<TbTrash size={16} />}
onClick={() => openUnsubscribeModal()}
loading={unsubscribe.loading}
>
<Trans>Unsubscribe</Trans>
</Button>
</Group>
</Stack>
</form>
</Container>
)
}

View File

@@ -1,101 +1,102 @@
import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks"
import { Constants } from "app/constants"
import { type EntrySourceType } from "app/entries/slice"
import { loadEntries } from "app/entries/thunks"
import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { flattenCategoryTree } from "app/utils"
import { FeedEntries } from "components/content/FeedEntries"
import { useEffect } from "react"
import { TbEdit } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom"
import { tss } from "tss"
function NoSubscriptionHelp() {
return (
<Box>
<Center>
<Trans>
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
</Trans>
</Center>
</Box>
)
}
interface FeedEntriesPageProps {
sourceType: EntrySourceType
}
const useStyles = tss.create(() => ({
sourceWebsiteLink: {
color: "inherit",
textDecoration: "none",
},
}))
export function FeedEntriesPage(props: FeedEntriesPageProps) {
const { classes } = useStyles()
const location = useLocation()
const { id = Constants.categories.all.id } = useParams()
const viewport = useViewportSize()
const theme = useMantineTheme()
const rootCategory = useAppSelector(state => state.tree.rootCategory)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
const hasMore = useAppSelector(state => state.entries.hasMore)
const dispatch = useAppDispatch()
const titleClicked = () => {
switch (props.sourceType) {
case "category":
dispatch(redirectToCategoryDetails(id))
break
case "feed":
dispatch(redirectToFeedDetails(id))
break
case "tag":
dispatch(redirectToTagDetails(id))
break
}
}
useEffect(() => {
dispatch(
loadEntries({
source: {
type: props.sourceType,
id,
},
clearSearch: true,
})
)
}, [dispatch, props.sourceType, id, location.state])
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
if (noSubscriptions) return <NoSubscriptionHelp />
return (
// add some room at the bottom of the page in order to be able to scroll the current entry at the top of the page when expanding
<Box mb={viewport.height * 0.75}>
<Group gap="xl">
{sourceWebsiteUrl && (
<a href={sourceWebsiteUrl} target="_blank" rel="noreferrer" className={classes.sourceWebsiteLink}>
<Title order={3}>{sourceLabel}</Title>
</a>
)}
{!sourceWebsiteUrl && <Title order={3}>{sourceLabel}</Title>}
{sourceLabel && (
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} />
</ActionIcon>
)}
</Group>
<FeedEntries />
{!hasMore && <Divider my="xl" label={<Trans>No more entries</Trans>} labelPosition="center" />}
</Box>
)
}
import { Trans } from "@lingui/macro"
import { ActionIcon, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
import { useViewportSize } from "@mantine/hooks"
import { Constants } from "app/constants"
import type { EntrySourceType } from "app/entries/slice"
import { loadEntries } from "app/entries/thunks"
import { redirectToCategoryDetails, redirectToFeedDetails, redirectToTagDetails } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { flattenCategoryTree } from "app/utils"
import { FeedEntries } from "components/content/FeedEntries"
import { useEffect } from "react"
import { TbEdit } from "react-icons/tb"
import { useLocation, useParams } from "react-router-dom"
import { tss } from "tss"
function NoSubscriptionHelp() {
return (
<Box>
<Center>
<Trans>
You don't have any subscriptions yet. Why not try adding one by clicking on the + sign at the top of the page?
</Trans>
</Center>
</Box>
)
}
interface FeedEntriesPageProps {
sourceType: EntrySourceType
}
const useStyles = tss.create(() => ({
sourceWebsiteLink: {
color: "inherit",
textDecoration: "none",
},
}))
export function FeedEntriesPage(props: FeedEntriesPageProps) {
const { classes } = useStyles()
const location = useLocation()
const { id = Constants.categories.all.id } = useParams()
const viewport = useViewportSize()
const theme = useMantineTheme()
const rootCategory = useAppSelector(state => state.tree.rootCategory)
const sourceLabel = useAppSelector(state => state.entries.sourceLabel)
const sourceWebsiteUrl = useAppSelector(state => state.entries.sourceWebsiteUrl)
const hasMore = useAppSelector(state => state.entries.hasMore)
const dispatch = useAppDispatch()
const titleClicked = () => {
switch (props.sourceType) {
case "category":
dispatch(redirectToCategoryDetails(id))
break
case "feed":
dispatch(redirectToFeedDetails(id))
break
case "tag":
dispatch(redirectToTagDetails(id))
break
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: we subscribe to state.timestamp because we want to reload entries even if the props are the same
useEffect(() => {
dispatch(
loadEntries({
source: {
type: props.sourceType,
id,
},
clearSearch: true,
})
)
}, [dispatch, props.sourceType, id, location.state?.timestamp])
const noSubscriptions = rootCategory && flattenCategoryTree(rootCategory).every(c => c.feeds.length === 0)
if (noSubscriptions) return <NoSubscriptionHelp />
return (
// add some room at the bottom of the page in order to be able to scroll the current entry at the top of the page when expanding
<Box mb={viewport.height * 0.75}>
<Group gap="xl">
{sourceWebsiteUrl && (
<a href={sourceWebsiteUrl} target="_blank" rel="noreferrer" className={classes.sourceWebsiteLink}>
<Title order={3}>{sourceLabel}</Title>
</a>
)}
{!sourceWebsiteUrl && <Title order={3}>{sourceLabel}</Title>}
{sourceLabel && (
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
<TbEdit size={18} />
</ActionIcon>
)}
</Group>
<FeedEntries />
{!hasMore && <Divider my="xl" label={<Trans>No more entries</Trans>} labelPosition="center" />}
</Box>
)
}

View File

@@ -1,217 +1,217 @@
import { Trans } from "@lingui/macro"
import { ActionIcon, AppShell, Box, Center, Group, ScrollArea, Title, useMantineTheme } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToAdd, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { setMobileMenuOpen } from "app/tree/slice"
import { reloadTree } from "app/tree/thunks"
import { reloadProfile, reloadSettings, reloadTags } from "app/user/thunks"
import { ActionButton } from "components/ActionButton"
import { AnnouncementDialog } from "components/AnnouncementDialog"
import { Loader } from "components/Loader"
import { Logo } from "components/Logo"
import { OnDesktop } from "components/responsive/OnDesktop"
import { OnMobile } from "components/responsive/OnMobile"
import { useAppLoading } from "hooks/useAppLoading"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
import { useWebSocket } from "hooks/useWebSocket"
import { LoadingPage } from "pages/LoadingPage"
import { type ReactNode, Suspense, useEffect } from "react"
import Draggable from "react-draggable"
import { TbMenu2, TbPlus, TbX } from "react-icons/tb"
import { Outlet } from "react-router-dom"
import { useSwipeable } from "react-swipeable"
import { tss } from "tss"
import useLocalStorage from "use-local-storage"
interface LayoutProps {
sidebar: ReactNode
sidebarVisible: boolean
header: ReactNode
}
function LogoAndTitle() {
const dispatch = useAppDispatch()
return (
<Center inline onClick={async () => await dispatch(redirectToRootCategory())} style={{ cursor: "pointer" }}>
<Logo size={24} />
<Title order={3} pl="md">
CommaFeed
</Title>
</Center>
)
}
const useStyles = tss
.withParams<{
sidebarWidth: number
sidebarPadding: string
sidebarRightBorderWidth: string
}>()
.create(({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth }) => {
return {
sidebarContent: {
maxWidth: `calc(${sidebarWidth}px - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
maxWidth: `calc(100vw - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
},
},
}
})
export default function Layout(props: LayoutProps) {
const theme = useMantineTheme()
const mobile = useMobile()
const { isBrowserExtensionPopup } = useBrowserExtension()
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar-width", 350)
const sidebarPadding = theme.spacing.xs
const { classes } = useStyles({
sidebarWidth,
sidebarPadding,
sidebarRightBorderWidth: "1px",
})
const { loading } = useAppLoading()
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
useWebSocket()
useEffect(() => {
// load initial data
dispatch(reloadSettings())
dispatch(reloadProfile())
dispatch(reloadTree())
dispatch(reloadTags())
}, [dispatch])
useEffect(() => {
let timer: number | undefined
if (!webSocketConnected && treeReloadInterval) {
// reload tree periodically if not receiving websocket events
timer = window.setInterval(async () => await dispatch(reloadTree()), treeReloadInterval)
}
return () => clearInterval(timer)
}, [dispatch, webSocketConnected, treeReloadInterval])
const burger = (
<ActionButton
label={mobileMenuOpen ? <Trans>Close menu</Trans> : <Trans>Open menu</Trans>}
icon={mobileMenuOpen ? <TbX size={18} /> : <TbMenu2 size={18} />}
onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))}
></ActionButton>
)
const addButton = (
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={async () => await dispatch(redirectToAdd())}
aria-label="Subscribe"
>
<TbPlus size={18} />
</ActionIcon>
)
const header = (
<>
<OnMobile>
{mobileMenuOpen && (
<Group justify="space-between" p="md">
<Box>{burger}</Box>
<Box>
<LogoAndTitle />
</Box>
<Box>{addButton}</Box>
</Group>
)}
{!mobileMenuOpen && (
<Group p="md">
<Box>{burger}</Box>
<Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group>
)}
</OnMobile>
<OnDesktop>
<Group p="md">
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
<Box>
<LogoAndTitle />
</Box>
<Box>{addButton}</Box>
</Group>
<Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group>
</OnDesktop>
</>
)
const swipeHandlers = useSwipeable({
onSwiping: e => {
const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right"))
}
},
})
if (loading) return <LoadingPage />
return (
<Box {...swipeHandlers}>
<AppShell
header={{ height: Constants.layout.headerHeight, collapsed: headerInFooter }}
footer={{ height: Constants.layout.headerHeight, collapsed: !headerInFooter }}
navbar={{
width: sidebarWidth,
breakpoint: Constants.layout.mobileBreakpoint,
collapsed: { mobile: !mobileMenuOpen, desktop: !props.sidebarVisible },
}}
padding={{ base: 6, [Constants.layout.mobileBreakpointName]: "md" }}
>
<AppShell.Header id={Constants.dom.headerId}>{!headerInFooter && header}</AppShell.Header>
<AppShell.Footer id={Constants.dom.footerId}>{headerInFooter && header}</AppShell.Footer>
<AppShell.Navbar id="sidebar" p={sidebarPadding}>
<AppShell.Section grow component={ScrollArea} mx="-sm" px="sm">
<Box className={classes.sidebarContent}>{props.sidebar}</Box>
</AppShell.Section>
</AppShell.Navbar>
<OnDesktop>
<Draggable
axis="x"
defaultPosition={{
x: sidebarWidth,
y: 0,
}}
bounds={{
left: 120,
right: 1000,
}}
grid={[30, 30]}
onDrag={(_e, data) => setSidebarWidth(data.x)}
>
<Box
style={{
position: "fixed",
height: "100%",
width: "10px",
cursor: "ew-resize",
}}
></Box>
</Draggable>
</OnDesktop>
<AppShell.Main id="content">
<Suspense fallback={<Loader />}>
<AnnouncementDialog />
<Outlet />
</Suspense>
</AppShell.Main>
</AppShell>
</Box>
)
}
import { Trans } from "@lingui/macro"
import { ActionIcon, AppShell, Box, Center, Group, ScrollArea, Title, useMantineTheme } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToAdd, redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { setMobileMenuOpen } from "app/tree/slice"
import { reloadTree } from "app/tree/thunks"
import { reloadProfile, reloadSettings, reloadTags } from "app/user/thunks"
import { ActionButton } from "components/ActionButton"
import { AnnouncementDialog } from "components/AnnouncementDialog"
import { Loader } from "components/Loader"
import { Logo } from "components/Logo"
import { OnDesktop } from "components/responsive/OnDesktop"
import { OnMobile } from "components/responsive/OnMobile"
import { useAppLoading } from "hooks/useAppLoading"
import { useBrowserExtension } from "hooks/useBrowserExtension"
import { useMobile } from "hooks/useMobile"
import { useWebSocket } from "hooks/useWebSocket"
import { LoadingPage } from "pages/LoadingPage"
import { type ReactNode, Suspense, useEffect } from "react"
import Draggable from "react-draggable"
import { TbMenu2, TbPlus, TbX } from "react-icons/tb"
import { Outlet } from "react-router-dom"
import { useSwipeable } from "react-swipeable"
import { tss } from "tss"
import useLocalStorage from "use-local-storage"
interface LayoutProps {
sidebar: ReactNode
sidebarVisible: boolean
header: ReactNode
}
function LogoAndTitle() {
const dispatch = useAppDispatch()
return (
<Center inline onClick={async () => await dispatch(redirectToRootCategory())} style={{ cursor: "pointer" }}>
<Logo size={24} />
<Title order={3} pl="md">
CommaFeed
</Title>
</Center>
)
}
const useStyles = tss
.withParams<{
sidebarWidth: number
sidebarPadding: string
sidebarRightBorderWidth: string
}>()
.create(({ sidebarWidth, sidebarPadding, sidebarRightBorderWidth }) => {
return {
sidebarContent: {
maxWidth: `calc(${sidebarWidth}px - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
[`@media (max-width: ${Constants.layout.mobileBreakpoint}px)`]: {
maxWidth: `calc(100vw - ${sidebarPadding} * 2 - ${sidebarRightBorderWidth})`,
},
},
}
})
export default function Layout(props: LayoutProps) {
const theme = useMantineTheme()
const mobile = useMobile()
const { isBrowserExtensionPopup } = useBrowserExtension()
const [sidebarWidth, setSidebarWidth] = useLocalStorage("sidebar-width", 350)
const sidebarPadding = theme.spacing.xs
const { classes } = useStyles({
sidebarWidth,
sidebarPadding,
sidebarRightBorderWidth: "1px",
})
const { loading } = useAppLoading()
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
const webSocketConnected = useAppSelector(state => state.server.webSocketConnected)
const treeReloadInterval = useAppSelector(state => state.server.serverInfos?.treeReloadInterval)
const mobileFooter = useAppSelector(state => state.user.settings?.mobileFooter)
const headerInFooter = mobile && !isBrowserExtensionPopup && mobileFooter
const dispatch = useAppDispatch()
useWebSocket()
useEffect(() => {
// load initial data
dispatch(reloadSettings())
dispatch(reloadProfile())
dispatch(reloadTree())
dispatch(reloadTags())
}, [dispatch])
useEffect(() => {
let timer: number | undefined
if (!webSocketConnected && treeReloadInterval) {
// reload tree periodically if not receiving websocket events
timer = window.setInterval(async () => await dispatch(reloadTree()), treeReloadInterval)
}
return () => clearInterval(timer)
}, [dispatch, webSocketConnected, treeReloadInterval])
const burger = (
<ActionButton
label={mobileMenuOpen ? <Trans>Close menu</Trans> : <Trans>Open menu</Trans>}
icon={mobileMenuOpen ? <TbX size={18} /> : <TbMenu2 size={18} />}
onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))}
/>
)
const addButton = (
<ActionIcon
color={theme.primaryColor}
variant="subtle"
onClick={async () => await dispatch(redirectToAdd())}
aria-label="Subscribe"
>
<TbPlus size={18} />
</ActionIcon>
)
const header = (
<>
<OnMobile>
{mobileMenuOpen && (
<Group justify="space-between" p="md">
<Box>{burger}</Box>
<Box>
<LogoAndTitle />
</Box>
<Box>{addButton}</Box>
</Group>
)}
{!mobileMenuOpen && (
<Group p="md">
<Box>{burger}</Box>
<Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group>
)}
</OnMobile>
<OnDesktop>
<Group p="md">
<Group justify="space-between" style={{ width: sidebarWidth - 16 }}>
<Box>
<LogoAndTitle />
</Box>
<Box>{addButton}</Box>
</Group>
<Box style={{ flexGrow: 1 }}>{props.header}</Box>
</Group>
</OnDesktop>
</>
)
const swipeHandlers = useSwipeable({
onSwiping: e => {
const threshold = document.documentElement.clientWidth / 6
if (e.absX > threshold) {
dispatch(setMobileMenuOpen(e.dir === "Right"))
}
},
})
if (loading) return <LoadingPage />
return (
<Box {...swipeHandlers}>
<AppShell
header={{ height: Constants.layout.headerHeight, collapsed: headerInFooter }}
footer={{ height: Constants.layout.headerHeight, collapsed: !headerInFooter }}
navbar={{
width: sidebarWidth,
breakpoint: Constants.layout.mobileBreakpoint,
collapsed: { mobile: !mobileMenuOpen, desktop: !props.sidebarVisible },
}}
padding={{ base: 6, [Constants.layout.mobileBreakpointName]: "md" }}
>
<AppShell.Header id={Constants.dom.headerId}>{!headerInFooter && header}</AppShell.Header>
<AppShell.Footer id={Constants.dom.footerId}>{headerInFooter && header}</AppShell.Footer>
<AppShell.Navbar id="sidebar" p={sidebarPadding}>
<AppShell.Section grow component={ScrollArea} mx="-sm" px="sm">
<Box className={classes.sidebarContent}>{props.sidebar}</Box>
</AppShell.Section>
</AppShell.Navbar>
<OnDesktop>
<Draggable
axis="x"
defaultPosition={{
x: sidebarWidth,
y: 0,
}}
bounds={{
left: 120,
right: 1000,
}}
grid={[30, 30]}
onDrag={(_e, data) => setSidebarWidth(data.x)}
>
<Box
style={{
position: "fixed",
height: "100%",
width: "10px",
cursor: "ew-resize",
}}
/>
</Draggable>
</OnDesktop>
<AppShell.Main id="content">
<Suspense fallback={<Loader />}>
<AnnouncementDialog />
<Outlet />
</Suspense>
</AppShell.Main>
</AppShell>
</Box>
)
}

View File

@@ -1,38 +1,38 @@
import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core"
import { CustomCodeSettings } from "components/settings/CustomCodeSettings"
import { DisplaySettings } from "components/settings/DisplaySettings"
import { ProfileSettings } from "components/settings/ProfileSettings"
import { TbCode, TbPhoto, TbUser } from "react-icons/tb"
export function SettingsPage() {
return (
<Container size="sm" px={0}>
<Tabs defaultValue="display" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
<Trans>Display</Trans>
</Tabs.Tab>
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
<Trans>Custom code</Trans>
</Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<TbUser size={16} />}>
<Trans>Profile</Trans>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="display" pt="xl">
<DisplaySettings />
</Tabs.Panel>
<Tabs.Panel value="customCode" pt="xl">
<CustomCodeSettings />
</Tabs.Panel>
<Tabs.Panel value="profile" pt="xl">
<ProfileSettings />
</Tabs.Panel>
</Tabs>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { Container, Tabs } from "@mantine/core"
import { CustomCodeSettings } from "components/settings/CustomCodeSettings"
import { DisplaySettings } from "components/settings/DisplaySettings"
import { ProfileSettings } from "components/settings/ProfileSettings"
import { TbCode, TbPhoto, TbUser } from "react-icons/tb"
export function SettingsPage() {
return (
<Container size="sm" px={0}>
<Tabs defaultValue="display" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="display" leftSection={<TbPhoto size={16} />}>
<Trans>Display</Trans>
</Tabs.Tab>
<Tabs.Tab value="customCode" leftSection={<TbCode size={16} />}>
<Trans>Custom code</Trans>
</Tabs.Tab>
<Tabs.Tab value="profile" leftSection={<TbUser size={16} />}>
<Trans>Profile</Trans>
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="display" pt="xl">
<DisplaySettings />
</Tabs.Panel>
<Tabs.Panel value="customCode" pt="xl">
<CustomCodeSettings />
</Tabs.Panel>
<Tabs.Panel value="profile" pt="xl">
<ProfileSettings />
</Tabs.Panel>
</Tabs>
</Container>
)
}

View File

@@ -1,42 +1,42 @@
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Container, Group, Input, Stack, Title } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { useParams } from "react-router-dom"
export function TagDetailsPage() {
const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
return (
<Container>
<Stack>
<Title order={3}>{id}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor
href={`rest/category/entriesAsFeed?id=${Constants.categories.all.id}&apiKey=${apiKey}&tag=${id}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
</Group>
</Stack>
</Container>
)
}
import { Trans } from "@lingui/macro"
import { Anchor, Box, Button, Container, Group, Input, Stack, Title } from "@mantine/core"
import { Constants } from "app/constants"
import { redirectToSelectedSource } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { useParams } from "react-router-dom"
export function TagDetailsPage() {
const { id = Constants.categories.all.id } = useParams()
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
const dispatch = useAppDispatch()
return (
<Container>
<Stack>
<Title order={3}>{id}</Title>
<Input.Wrapper label={<Trans>Generated feed url</Trans>}>
<Box>
{apiKey && (
<Anchor
href={`rest/category/entriesAsFeed?id=${Constants.categories.all.id}&apiKey=${apiKey}&tag=${id}`}
target="_blank"
rel="noreferrer"
>
<Trans>Link</Trans>
</Anchor>
)}
{!apiKey && <Trans>Generate an API key in your profile first.</Trans>}
</Box>
</Input.Wrapper>
<Group>
<Button variant="default" onClick={async () => await dispatch(redirectToSelectedSource())}>
<Trans>Cancel</Trans>
</Button>
</Group>
</Stack>
</Container>
)
}

View File

@@ -1,89 +1,89 @@
import { t, Trans } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { type LoginRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function LoginPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch()
const form = useForm<LoginRequest>({
initialValues: {
name: "",
password: "",
},
})
const login = useAsyncCallback(client.user.login, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Log in</Trans>
</Title>
{login.error && (
<Box mb="md">
<Alert messages={errorToStrings(login.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(login.execute)}>
<Stack>
<TextInput
label={<Trans>User Name or E-mail</Trans>}
placeholder={t`User Name or E-mail`}
{...form.getInputProps("name")}
description={
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
}
size="md"
required
autoCapitalize="off"
/>
<PasswordInput
label={<Trans>Password</Trans>}
placeholder={t`Password`}
{...form.getInputProps("password")}
size="md"
required
/>
{serverInfos?.smtpEnabled && (
<Anchor component={Link} to="/passwordRecovery" c="dimmed">
<Trans>Forgot password?</Trans>
</Anchor>
)}
<Button type="submit" loading={login.loading}>
<Trans>Log in</Trans>
</Button>
{serverInfos?.allowRegistrations && (
<Center>
<Group>
<Trans>
<Box>Need an account?</Box>
<Anchor component={Link} to="/register">
Sign up!
</Anchor>
</Trans>
</Group>
</Center>
)}
</Stack>
</form>
</Paper>
</Container>
)
}
import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import type { LoginRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function LoginPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch()
const form = useForm<LoginRequest>({
initialValues: {
name: "",
password: "",
},
})
const login = useAsyncCallback(client.user.login, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Log in</Trans>
</Title>
{login.error && (
<Box mb="md">
<Alert messages={errorToStrings(login.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(login.execute)}>
<Stack>
<TextInput
label={<Trans>User Name or E-mail</Trans>}
placeholder={t`User Name or E-mail`}
{...form.getInputProps("name")}
description={
serverInfos?.demoAccountEnabled ? <Trans>Try out CommaFeed with the demo account: demo/demo</Trans> : ""
}
size="md"
required
autoCapitalize="off"
/>
<PasswordInput
label={<Trans>Password</Trans>}
placeholder={t`Password`}
{...form.getInputProps("password")}
size="md"
required
/>
{serverInfos?.smtpEnabled && (
<Anchor component={Link} to="/passwordRecovery" c="dimmed">
<Trans>Forgot password?</Trans>
</Anchor>
)}
<Button type="submit" loading={login.loading}>
<Trans>Log in</Trans>
</Button>
{serverInfos?.allowRegistrations && (
<Center>
<Group>
<Trans>
<Box>Need an account?</Box>
<Anchor component={Link} to="/register">
Sign up!
</Anchor>
</Trans>
</Group>
</Center>
)}
</Stack>
</form>
</Paper>
</Container>
)
}

View File

@@ -1,79 +1,79 @@
import { t, Trans } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import { type PasswordResetRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useState } from "react"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function PasswordRecoveryPage() {
const [message, setMessage] = useState("")
const form = useForm<PasswordResetRequest>({
initialValues: {
email: "",
},
})
const recoverPassword = useAsyncCallback(client.user.passwordReset, {
onSuccess: () => {
setMessage(t`An email has been sent if this address was registered. Check your inbox.`)
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Password Recovery</Trans>
</Title>
{recoverPassword.error && (
<Box mb="md">
<Alert messages={errorToStrings(recoverPassword.error)} />
</Box>
)}
{message && (
<Box mb="md">
<Alert level="success" messages={[message]} />
</Box>
)}
<form
onSubmit={form.onSubmit(req => {
setMessage("")
recoverPassword.execute(req)
})}
>
<Stack>
<TextInput
type="email"
label={<Trans>E-mail</Trans>}
placeholder={t`E-mail`}
{...form.getInputProps("email")}
size="md"
required
/>
<Button type="submit" loading={recoverPassword.loading}>
<Trans>Recover password</Trans>
</Button>
<Center>
<Group>
<Anchor component={Link} to="/login">
<Trans>Back to log in</Trans>
</Anchor>
</Group>
</Center>
</Stack>
</form>
</Paper>
</Container>
)
}
import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import type { PasswordResetRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useState } from "react"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function PasswordRecoveryPage() {
const [message, setMessage] = useState("")
const form = useForm<PasswordResetRequest>({
initialValues: {
email: "",
},
})
const recoverPassword = useAsyncCallback(client.user.passwordReset, {
onSuccess: () => {
setMessage(t`An email has been sent if this address was registered. Check your inbox.`)
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Password Recovery</Trans>
</Title>
{recoverPassword.error && (
<Box mb="md">
<Alert messages={errorToStrings(recoverPassword.error)} />
</Box>
)}
{message && (
<Box mb="md">
<Alert level="success" messages={[message]} />
</Box>
)}
<form
onSubmit={form.onSubmit(req => {
setMessage("")
recoverPassword.execute(req)
})}
>
<Stack>
<TextInput
type="email"
label={<Trans>E-mail</Trans>}
placeholder={t`E-mail`}
{...form.getInputProps("email")}
size="md"
required
/>
<Button type="submit" loading={recoverPassword.loading}>
<Trans>Recover password</Trans>
</Button>
<Center>
<Group>
<Anchor component={Link} to="/login">
<Trans>Back to log in</Trans>
</Anchor>
</Group>
</Center>
</Stack>
</form>
</Paper>
</Container>
)
}

View File

@@ -1,89 +1,89 @@
import { t, Trans } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import { type RegistrationRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function RegistrationPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch()
const form = useForm<RegistrationRequest>({
initialValues: {
name: "",
password: "",
email: "",
},
})
const register = useAsyncCallback(client.user.register, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Sign up</Trans>
</Title>
{serverInfos && !serverInfos.allowRegistrations && (
<Box mb="md">
<Alert messages={[t`Registrations are closed on this CommaFeed instance`]} />
</Box>
)}
{serverInfos?.allowRegistrations && (
<>
{register.error && (
<Box mb="md">
<Alert messages={errorToStrings(register.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(register.execute)}>
<Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
<TextInput
type="email"
label={<Trans>E-mail address</Trans>}
placeholder={t`E-mail address`}
{...form.getInputProps("email")}
size="md"
required
/>
<PasswordInput
label={<Trans>Password</Trans>}
placeholder={t`Password`}
{...form.getInputProps("password")}
size="md"
required
/>
<Button type="submit" loading={register.loading}>
<Trans>Sign up</Trans>
</Button>
<Center>
<Group>
<Trans>
<Box>Have an account?</Box>
<Anchor component={Link} to="/login">
Log in!
</Anchor>
</Trans>
</Group>
</Center>
</Stack>
</form>
</>
)}
</Paper>
</Container>
)
}
import { Trans, t } from "@lingui/macro"
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
import { useForm } from "@mantine/form"
import { client, errorToStrings } from "app/client"
import { redirectToRootCategory } from "app/redirect/thunks"
import { useAppDispatch, useAppSelector } from "app/store"
import type { RegistrationRequest } from "app/types"
import { Alert } from "components/Alert"
import { PageTitle } from "pages/PageTitle"
import { useAsyncCallback } from "react-async-hook"
import { Link } from "react-router-dom"
export function RegistrationPage() {
const serverInfos = useAppSelector(state => state.server.serverInfos)
const dispatch = useAppDispatch()
const form = useForm<RegistrationRequest>({
initialValues: {
name: "",
password: "",
email: "",
},
})
const register = useAsyncCallback(client.user.register, {
onSuccess: () => {
dispatch(redirectToRootCategory())
},
})
return (
<Container size="xs">
<PageTitle />
<Paper>
<Title order={2} mb="md">
<Trans>Sign up</Trans>
</Title>
{serverInfos && !serverInfos.allowRegistrations && (
<Box mb="md">
<Alert messages={[t`Registrations are closed on this CommaFeed instance`]} />
</Box>
)}
{serverInfos?.allowRegistrations && (
<>
{register.error && (
<Box mb="md">
<Alert messages={errorToStrings(register.error)} />
</Box>
)}
<form onSubmit={form.onSubmit(register.execute)}>
<Stack>
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
<TextInput
type="email"
label={<Trans>E-mail address</Trans>}
placeholder={t`E-mail address`}
{...form.getInputProps("email")}
size="md"
required
/>
<PasswordInput
label={<Trans>Password</Trans>}
placeholder={t`Password`}
{...form.getInputProps("password")}
size="md"
required
/>
<Button type="submit" loading={register.loading}>
<Trans>Sign up</Trans>
</Button>
<Center>
<Group>
<Trans>
<Box>Have an account?</Box>
<Anchor component={Link} to="/login">
Log in!
</Anchor>
</Trans>
</Group>
</Center>
</Stack>
</form>
</>
)}
</Paper>
</Container>
)
}