mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
replace complex eslint config with biome
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user