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,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>
)
}