mirror of
https://github.com/Athou/commafeed.git
synced 2026-03-21 21:37:29 +00:00
replace old client with new client from commafeed-ui repository
This commit is contained in:
32
commafeed-client/src/pages/LoadingPage.tsx
Normal file
32
commafeed-client/src/pages/LoadingPage.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Center, Container, RingProgress, Text, Title, useMantineTheme } from "@mantine/core"
|
||||
import { Logo } from "components/Logo"
|
||||
import { useAppLoading } from "hooks/useAppLoading"
|
||||
|
||||
export function LoadingPage() {
|
||||
const theme = useMantineTheme()
|
||||
const { loadingPercentage, loadingStepLabel } = useAppLoading()
|
||||
|
||||
return (
|
||||
<Container size="xs">
|
||||
<Center my="xl">
|
||||
<Logo size={48} />
|
||||
<Title order={1} ml="md">
|
||||
CommaFeed
|
||||
</Title>
|
||||
</Center>
|
||||
|
||||
<Center>
|
||||
<RingProgress
|
||||
sections={[{ value: loadingPercentage, color: theme.primaryColor }]}
|
||||
label={
|
||||
<Text weight="bold" align="center" size="xl">
|
||||
{loadingPercentage}%
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Center>
|
||||
|
||||
{loadingStepLabel && <Center>{loadingStepLabel}</Center>}
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
149
commafeed-client/src/pages/admin/AdminUsersPage.tsx
Normal file
149
commafeed-client/src/pages/admin/AdminUsersPage.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { ActionIcon, Box, Code, Container, Group, Table, Text, Title, useMantineTheme } from "@mantine/core"
|
||||
import { closeAllModals, openConfirmModal, openModal } from "@mantine/modals"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { UserModel } from "app/types"
|
||||
import { UserEdit } from "components/admin/UserEdit"
|
||||
import { Alert } from "components/Alert"
|
||||
import { Loader } from "components/Loader"
|
||||
import { RelativeDate } from "components/RelativeDate"
|
||||
import { useAsync } from "react-async-hook"
|
||||
import { TbCheck, TbPencil, TbPlus, TbTrash, TbX } from "react-icons/tb"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
function BooleanIcon({ value }: { value: boolean }) {
|
||||
return value ? <TbCheck size={18} /> : <TbX size={18} />
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const theme = useMantineTheme()
|
||||
const query = useAsync(() => client.admin.getAllUsers(), [])
|
||||
const users = query.result?.data.sort((a, b) => a.id - b.id)
|
||||
|
||||
const [deleteUser, deleteUserResult] = useMutation(client.admin.deleteUser, {
|
||||
onSuccess: () => {
|
||||
query.execute()
|
||||
closeAllModals()
|
||||
},
|
||||
})
|
||||
const errors = errorToStrings(deleteUserResult.error)
|
||||
|
||||
const openUserEditModal = (title: string, user?: UserModel) => {
|
||||
openModal({
|
||||
title,
|
||||
children: (
|
||||
<UserEdit
|
||||
user={user}
|
||||
onCancel={closeAllModals}
|
||||
onSave={() => {
|
||||
query.execute()
|
||||
closeAllModals()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
const openUserDeleteModal = (user: UserModel) => {
|
||||
const userName = user.name
|
||||
openConfirmModal({
|
||||
title: t`Delete user`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
<Trans>
|
||||
Are you sure you want to delete user <Code>{userName}</Code> ?
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t`Confirm`, cancel: t`Cancel` },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteUser({ id: user.id }),
|
||||
})
|
||||
}
|
||||
|
||||
if (!users) return <Loader />
|
||||
return (
|
||||
<Container>
|
||||
<Title order={3} mb="md">
|
||||
<Group>
|
||||
<Trans>Manage users</Trans>
|
||||
<ActionIcon color={theme.primaryColor} onClick={() => openUserEditModal(t`Add user`)}>
|
||||
<TbPlus size={20} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Title>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<Trans>Id</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Name</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>E-mail</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Date created</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Last login date</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Admin</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Enabled</Trans>
|
||||
</th>
|
||||
<th>
|
||||
<Trans>Actions</Trans>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users?.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td>{u.id}</td>
|
||||
<td>{u.name}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
<RelativeDate date={u.created} />
|
||||
</td>
|
||||
<td>
|
||||
<RelativeDate date={u.lastLogin} />
|
||||
</td>
|
||||
<td>
|
||||
<BooleanIcon value={u.admin} />
|
||||
</td>
|
||||
<td>
|
||||
<BooleanIcon value={u.enabled} />
|
||||
</td>
|
||||
<td>
|
||||
<Group>
|
||||
<ActionIcon color={theme.primaryColor} onClick={() => openUserEditModal(t`Edit user`, u)}>
|
||||
<TbPencil size={18} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
color={theme.primaryColor}
|
||||
onClick={() => openUserDeleteModal(u)}
|
||||
loading={deleteUserResult.status === "running"}
|
||||
>
|
||||
<TbTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
38
commafeed-client/src/pages/app/AddPage.tsx
Normal file
38
commafeed-client/src/pages/app/AddPage.tsx
Normal file
@@ -0,0 +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" icon={<TbRss />}>
|
||||
<Trans>Subscribe</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="category" icon={<TbFolderPlus />}>
|
||||
<Trans>Add category</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="opml" icon={<TbFileImport />}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
125
commafeed-client/src/pages/app/CategoryDetailsPage.tsx
Normal file
125
commafeed-client/src/pages/app/CategoryDetailsPage.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { t, 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/slices/redirect"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { 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 } from "react-async-hook"
|
||||
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
||||
import { useParams } from "react-router-dom"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
export function CategoryDetailsPage() {
|
||||
const { id = Constants.categoryIds.all } = useParams()
|
||||
|
||||
const apiKey = useAppSelector(state => state.user.profile?.apiKey)
|
||||
const dispatch = useAppDispatch()
|
||||
const query = useAsync(() => client.category.getRoot(), [])
|
||||
const category = query.result && flattenCategoryTree(query.result.data).find(c => c.id === id)
|
||||
|
||||
const form = useForm<CategoryModificationRequest>()
|
||||
const { setValues } = form
|
||||
|
||||
const [modify, modifyResult] = useMutation(client.category.modify, {
|
||||
onSuccess: () => {
|
||||
dispatch(reloadTree())
|
||||
dispatch(redirectToSelectedSource())
|
||||
},
|
||||
})
|
||||
const [deleteCategory, deleteCategoryResult] = useMutation(client.category.delete, {
|
||||
onSuccess: () => {
|
||||
dispatch(reloadTree())
|
||||
dispatch(redirectToRootCategory())
|
||||
},
|
||||
})
|
||||
const errors = [...errorToStrings(modifyResult.error), ...errorToStrings(deleteCategoryResult.error)]
|
||||
|
||||
const openDeleteCategoryModal = () => {
|
||||
const categoryName = category?.name
|
||||
return openConfirmModal({
|
||||
title: t`Delete Category`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
<Trans>
|
||||
Are you sure you want to delete category <Code>{categoryName}</Code>?
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t`Confirm`, cancel: t`Cancel` },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => deleteCategory({ id: +id }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!category) return
|
||||
setValues({
|
||||
id: +category.id,
|
||||
name: category.name,
|
||||
parentId: category.parentId,
|
||||
position: category.position,
|
||||
})
|
||||
}, [setValues, category])
|
||||
|
||||
if (!category) return <Loader />
|
||||
return (
|
||||
<Container>
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit(modify)}>
|
||||
<Stack>
|
||||
<Title order={3}>{category.name}</Title>
|
||||
<Input.Wrapper label={t`Generated feed url`}>
|
||||
<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>
|
||||
|
||||
<TextInput label={t`Name`} {...form.getInputProps("name")} required />
|
||||
<CategorySelect label={t`Parent Category`} {...form.getInputProps("parentId")} clearable />
|
||||
<NumberInput label={t`Position`} {...form.getInputProps("position")} required min={0} />
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbDeviceFloppy size={16} />} loading={modifyResult.status === "running"}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
color="red"
|
||||
leftIcon={<TbTrash size={16} />}
|
||||
onClick={() => openDeleteCategoryModal()}
|
||||
loading={deleteCategoryResult.status === "running"}
|
||||
>
|
||||
<Trans>Delete</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
179
commafeed-client/src/pages/app/FeedDetailsPage.tsx
Normal file
179
commafeed-client/src/pages/app/FeedDetailsPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import { t, 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, errorsToStrings } from "app/client"
|
||||
import { redirectToRootCategory, redirectToSelectedSource } from "app/slices/redirect"
|
||||
import { reloadTree } from "app/slices/tree"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { 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 } from "react-async-hook"
|
||||
import { TbDeviceFloppy, TbTrash } from "react-icons/tb"
|
||||
import { useParams } from "react-router-dom"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
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 available syntax is available </span>
|
||||
<a href="http://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(() => client.feed.get(id), [id])
|
||||
const feed = query.result?.data
|
||||
|
||||
const form = useForm<FeedModificationRequest>()
|
||||
const { setValues } = form
|
||||
|
||||
const [modify, modifyResult] = useMutation(client.feed.modify, {
|
||||
onSuccess: () => {
|
||||
dispatch(reloadTree())
|
||||
dispatch(redirectToSelectedSource())
|
||||
},
|
||||
})
|
||||
const [unsubscribe, unsubscribeResult] = useMutation(client.feed.unsubscribe, {
|
||||
onSuccess: () => {
|
||||
dispatch(reloadTree())
|
||||
dispatch(redirectToRootCategory())
|
||||
},
|
||||
})
|
||||
const errors = errorsToStrings([modifyResult.error, unsubscribeResult.error])
|
||||
|
||||
const openUnsubscribeModal = () => {
|
||||
const feedName = feed?.name
|
||||
return openConfirmModal({
|
||||
title: t`Unsubscribe`,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
<Trans>
|
||||
Are you sure you want to unsubscribe from <Code>{feedName}</Code>?
|
||||
</Trans>
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t`Confirm`, cancel: t`Cancel` },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => unsubscribe({ id: +id }),
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!feed) return
|
||||
setValues(feed)
|
||||
}, [setValues, feed])
|
||||
|
||||
if (!feed) return <Loader />
|
||||
return (
|
||||
<Container>
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit(modify)}>
|
||||
<Stack>
|
||||
<Title order={3}>{feed.name}</Title>
|
||||
<Input.Wrapper label={t`Feed URL`}>
|
||||
<Box>
|
||||
<Anchor href={feed.feedUrl} target="_blank" rel="noreferrer">
|
||||
{feed.feedUrl}
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper label={t`Website`}>
|
||||
<Box>
|
||||
<Anchor href={feed.feedLink} target="_blank" rel="noreferrer">
|
||||
{feed.feedLink}
|
||||
</Anchor>
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper label={t`Last refresh`}>
|
||||
<Box>
|
||||
<RelativeDate date={feed.lastRefresh} />
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper label={t`Last refresh message`}>
|
||||
<Box>{feed.message ?? t`N/A`}</Box>
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper label={t`Next refresh`}>
|
||||
<Box>
|
||||
<RelativeDate date={feed.nextRefresh} />
|
||||
</Box>
|
||||
</Input.Wrapper>
|
||||
<Input.Wrapper label={t`Generated feed url`}>
|
||||
<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>
|
||||
|
||||
<TextInput label={t`Name`} {...form.getInputProps("name")} required />
|
||||
<CategorySelect label={t`Category`} {...form.getInputProps("categoryId")} clearable />
|
||||
<NumberInput label={t`Position`} {...form.getInputProps("position")} required min={0} />
|
||||
<TextInput
|
||||
label={t`Filtering expression`}
|
||||
description={<FilteringExpressionDescription />}
|
||||
{...form.getInputProps("filter")}
|
||||
/>
|
||||
|
||||
<Group>
|
||||
<Button variant="default" onClick={() => dispatch(redirectToSelectedSource())}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
<Button type="submit" leftIcon={<TbDeviceFloppy size={16} />} loading={modifyResult.status === "running"}>
|
||||
<Trans>Save</Trans>
|
||||
</Button>
|
||||
<Divider orientation="vertical" />
|
||||
<Button
|
||||
color="red"
|
||||
leftIcon={<TbTrash size={16} />}
|
||||
onClick={() => openUnsubscribeModal()}
|
||||
loading={unsubscribeResult.status === "running"}
|
||||
>
|
||||
<Trans>Unsubscribe</Trans>
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
84
commafeed-client/src/pages/app/FeedEntriesPage.tsx
Normal file
84
commafeed-client/src/pages/app/FeedEntriesPage.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { ActionIcon, Anchor, Box, Center, Divider, Group, Title, useMantineTheme } from "@mantine/core"
|
||||
import { useViewportSize } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { EntrySourceType, loadEntries } from "app/slices/entries"
|
||||
import { redirectToCategoryDetails, redirectToFeedDetails } from "app/slices/redirect"
|
||||
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"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function FeedEntriesPage(props: FeedEntriesPageProps) {
|
||||
const location = useLocation()
|
||||
const { id = Constants.categoryIds.all } = 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 readType = useAppSelector(state => state.user.settings?.readingMode)
|
||||
const order = useAppSelector(state => state.user.settings?.readingOrder)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const titleClicked = () => {
|
||||
if (props.sourceType === "category") dispatch(redirectToCategoryDetails(id))
|
||||
else dispatch(redirectToFeedDetails(id))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!readType || !order) return
|
||||
dispatch(
|
||||
loadEntries({
|
||||
sourceType: props.sourceType,
|
||||
req: { id, readType, order },
|
||||
})
|
||||
)
|
||||
}, [dispatch, props.sourceType, id, readType, order, location.state])
|
||||
|
||||
const hideEditButton = props.sourceType === "category" && id === Constants.categoryIds.all
|
||||
|
||||
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 - Constants.layout.headerHeight - 210}>
|
||||
<Group spacing="xl">
|
||||
{sourceWebsiteUrl && (
|
||||
<Anchor href={sourceWebsiteUrl} target="_blank" rel="noreferrer" variant="text">
|
||||
<Title order={3}>{sourceLabel}</Title>
|
||||
</Anchor>
|
||||
)}
|
||||
{!sourceWebsiteUrl && <Title order={3}>{sourceLabel}</Title>}
|
||||
{sourceLabel && !hideEditButton && (
|
||||
<ActionIcon onClick={titleClicked} variant="subtle" color={theme.primaryColor}>
|
||||
<TbEdit size={18} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<FeedEntries />
|
||||
|
||||
{!hasMore && <Divider my="xl" label={t`No more entries`} labelPosition="center" />}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
178
commafeed-client/src/pages/app/Layout.tsx
Normal file
178
commafeed-client/src/pages/app/Layout.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
ActionIcon,
|
||||
Anchor,
|
||||
AppShell,
|
||||
Box,
|
||||
Burger,
|
||||
Center,
|
||||
createStyles,
|
||||
DEFAULT_THEME,
|
||||
Group,
|
||||
Header,
|
||||
Navbar,
|
||||
ScrollArea,
|
||||
Title,
|
||||
useMantineTheme,
|
||||
} from "@mantine/core"
|
||||
import { useViewportSize } from "@mantine/hooks"
|
||||
import { Constants } from "app/constants"
|
||||
import { redirectToAdd, redirectToRootCategory } from "app/slices/redirect"
|
||||
import { reloadTree, setMobileMenuOpen } from "app/slices/tree"
|
||||
import { reloadProfile, reloadSettings } from "app/slices/user"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { Logo } from "components/Logo"
|
||||
import { OnDesktop } from "components/responsive/OnDesktop"
|
||||
import { OnMobile } from "components/responsive/OnMobile"
|
||||
import { useAppLoading } from "hooks/useAppLoading"
|
||||
import { LoadingPage } from "pages/LoadingPage"
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { TbPlus } from "react-icons/tb"
|
||||
import { Outlet } from "react-router-dom"
|
||||
|
||||
interface LayoutProps {
|
||||
sidebar: ReactNode
|
||||
header: ReactNode
|
||||
}
|
||||
|
||||
const sidebarPadding = DEFAULT_THEME.spacing.xs
|
||||
const sidebarRightBorderWidth = 1
|
||||
|
||||
const useStyles = createStyles(theme => ({
|
||||
sidebarContent: {
|
||||
maxWidth: Constants.layout.sidebarWidth - sidebarPadding * 2 - sidebarRightBorderWidth,
|
||||
[theme.fn.smallerThan(Constants.layout.mobileBreakpoint)]: {
|
||||
maxWidth: `calc(100vw - ${sidebarPadding * 2 + sidebarRightBorderWidth}px)`,
|
||||
},
|
||||
},
|
||||
mainContentWrapper: {
|
||||
paddingTop: Constants.layout.headerHeight,
|
||||
paddingLeft: Constants.layout.sidebarWidth,
|
||||
paddingRight: 0,
|
||||
paddingBottom: 0,
|
||||
[theme.fn.smallerThan(Constants.layout.mobileBreakpoint)]: {
|
||||
paddingLeft: 0,
|
||||
},
|
||||
},
|
||||
mainContent: {
|
||||
maxWidth: `calc(100vw - ${Constants.layout.sidebarWidth}px)`,
|
||||
padding: theme.spacing.md,
|
||||
[theme.fn.smallerThan(Constants.layout.mobileBreakpoint)]: {
|
||||
maxWidth: "100vw",
|
||||
padding: "6px",
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
function LogoAndTitle() {
|
||||
const dispatch = useAppDispatch()
|
||||
return (
|
||||
<Anchor onClick={() => dispatch(redirectToRootCategory())} variant="text">
|
||||
<Center inline>
|
||||
<Logo size={24} />
|
||||
<Title order={3} pl="md">
|
||||
CommaFeed
|
||||
</Title>
|
||||
</Center>
|
||||
</Anchor>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Layout({ sidebar, header }: LayoutProps) {
|
||||
const { classes } = useStyles()
|
||||
const theme = useMantineTheme()
|
||||
const viewport = useViewportSize()
|
||||
const { loading } = useAppLoading()
|
||||
const mobileMenuOpen = useAppSelector(state => state.tree.mobileMenuOpen)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(reloadSettings())
|
||||
dispatch(reloadProfile())
|
||||
dispatch(reloadTree())
|
||||
|
||||
// reload tree periodically
|
||||
const id = setInterval(() => dispatch(reloadTree()), 30000)
|
||||
return () => clearInterval(id)
|
||||
}, [dispatch])
|
||||
|
||||
const burger = (
|
||||
<Burger
|
||||
color={theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color}
|
||||
opened={mobileMenuOpen}
|
||||
onClick={() => dispatch(setMobileMenuOpen(!mobileMenuOpen))}
|
||||
size="sm"
|
||||
/>
|
||||
)
|
||||
|
||||
if (loading) return <LoadingPage />
|
||||
return (
|
||||
<AppShell
|
||||
fixed
|
||||
navbarOffsetBreakpoint={Constants.layout.mobileBreakpoint}
|
||||
classNames={{ main: classes.mainContentWrapper }}
|
||||
navbar={
|
||||
<Navbar
|
||||
p={sidebarPadding}
|
||||
hiddenBreakpoint={Constants.layout.mobileBreakpoint}
|
||||
hidden={!mobileMenuOpen}
|
||||
width={{ md: Constants.layout.sidebarWidth }}
|
||||
>
|
||||
<Navbar.Section grow component={ScrollArea} mx="-xs" px="xs">
|
||||
<Box className={classes.sidebarContent}>{sidebar}</Box>
|
||||
</Navbar.Section>
|
||||
</Navbar>
|
||||
}
|
||||
header={
|
||||
<Header height={Constants.layout.headerHeight} p="md">
|
||||
<OnMobile>
|
||||
{mobileMenuOpen && (
|
||||
<Group position="apart">
|
||||
<Box>{burger}</Box>
|
||||
<Box>
|
||||
<LogoAndTitle />
|
||||
</Box>
|
||||
<Box>
|
||||
<ActionIcon color={theme.primaryColor} onClick={() => dispatch(redirectToAdd())}>
|
||||
<TbPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Group>
|
||||
)}
|
||||
{!mobileMenuOpen && (
|
||||
<Group>
|
||||
<Box mr="sm">{burger}</Box>
|
||||
<Box sx={{ flexGrow: 1 }}>{header}</Box>
|
||||
</Group>
|
||||
)}
|
||||
</OnMobile>
|
||||
<OnDesktop>
|
||||
<Group>
|
||||
<Group position="apart" sx={{ width: Constants.layout.sidebarWidth - 16 }}>
|
||||
<Box>
|
||||
<LogoAndTitle />
|
||||
</Box>
|
||||
<Box>
|
||||
<ActionIcon color={theme.primaryColor} onClick={() => dispatch(redirectToAdd())}>
|
||||
<TbPlus size={18} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Group>
|
||||
<Box sx={{ flexGrow: 1 }}>{header}</Box>
|
||||
</Group>
|
||||
</OnDesktop>
|
||||
</Header>
|
||||
}
|
||||
>
|
||||
<ScrollArea
|
||||
sx={{ height: viewport.height - Constants.layout.headerHeight }}
|
||||
viewportRef={ref => {
|
||||
if (ref) ref.id = Constants.dom.mainScrollAreaId
|
||||
}}
|
||||
>
|
||||
<Box className={classes.mainContent}>
|
||||
<Outlet />
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
30
commafeed-client/src/pages/app/SettingsPage.tsx
Normal file
30
commafeed-client/src/pages/app/SettingsPage.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Trans } from "@lingui/macro"
|
||||
import { Container, Tabs } from "@mantine/core"
|
||||
import { DisplaySettings } from "components/settings/DisplaySettings"
|
||||
import { ProfileSettings } from "components/settings/ProfileSettings"
|
||||
import { TbPhoto, TbUser } from "react-icons/tb"
|
||||
|
||||
export function SettingsPage() {
|
||||
return (
|
||||
<Container size="sm" px={0}>
|
||||
<Tabs defaultValue="display">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="display" icon={<TbPhoto />}>
|
||||
<Trans>Display</Trans>
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="profile" icon={<TbUser />}>
|
||||
<Trans>Profile</Trans>
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="display" pt="xl">
|
||||
<DisplaySettings />
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="profile" pt="xl">
|
||||
<ProfileSettings />
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
91
commafeed-client/src/pages/auth/LoginPage.tsx
Normal file
91
commafeed-client/src/pages/auth/LoginPage.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToRootCategory } from "app/slices/redirect"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { LoginRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { Logo } from "components/Logo"
|
||||
import { Link } from "react-router-dom"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
export function LoginPage() {
|
||||
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const form = useForm<LoginRequest>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const [login, loginResult] = useMutation(client.user.login, {
|
||||
onSuccess: () => {
|
||||
dispatch(redirectToRootCategory())
|
||||
},
|
||||
})
|
||||
const errors = errorToStrings(loginResult.error)
|
||||
|
||||
return (
|
||||
<Container size="xs">
|
||||
<Center my="xl">
|
||||
<Logo size={48} />
|
||||
<Title order={1} ml="md">
|
||||
CommaFeed
|
||||
</Title>
|
||||
</Center>
|
||||
<Paper>
|
||||
<Title order={2} mb="md">
|
||||
<Trans>Log in</Trans>
|
||||
</Title>
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
<form onSubmit={form.onSubmit(login)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
label={t`User Name or E-mail`}
|
||||
placeholder={t`User Name or E-mail`}
|
||||
{...form.getInputProps("name")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t`Password`}
|
||||
placeholder={t`Password`}
|
||||
{...form.getInputProps("password")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
|
||||
{serverInfos?.smtpEnabled && (
|
||||
<Anchor component={Link} to="/passwordRecovery" color="dimmed">
|
||||
<Trans>Forgot password?</Trans>
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
<Button type="submit" loading={loginResult.status === "running"}>
|
||||
<Trans>Log in</Trans>
|
||||
</Button>
|
||||
{serverInfos?.allowRegistrations && (
|
||||
<Center>
|
||||
<Group>
|
||||
<Trans>
|
||||
<Box>Need an account?</Box>
|
||||
<Anchor component={Link} to="/register">
|
||||
Sign up!
|
||||
</Anchor>
|
||||
</Trans>
|
||||
</Group>
|
||||
</Center>
|
||||
)}
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
80
commafeed-client/src/pages/auth/PasswordRecoveryPage.tsx
Normal file
80
commafeed-client/src/pages/auth/PasswordRecoveryPage.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { Anchor, Box, Button, Center, Container, Group, Paper, Stack, TextInput, Title } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { PasswordResetRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { Logo } from "components/Logo"
|
||||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
export function PasswordRecoveryPage() {
|
||||
const [message, setMessage] = useState("")
|
||||
|
||||
const form = useForm<PasswordResetRequest>({
|
||||
initialValues: {
|
||||
email: "",
|
||||
},
|
||||
})
|
||||
|
||||
const [recoverPassword, recoverPasswordResult] = useMutation(client.user.passwordReset, {
|
||||
onMutate: () => {
|
||||
setMessage("")
|
||||
},
|
||||
onSuccess: () => {
|
||||
setMessage(t`An email has been sent if this address was registered. Check your inbox.`)
|
||||
},
|
||||
})
|
||||
const errors = errorToStrings(recoverPasswordResult.error)
|
||||
|
||||
return (
|
||||
<Container size="xs">
|
||||
<Center my="xl">
|
||||
<Logo size={48} />
|
||||
<Title order={1} ml="md">
|
||||
CommaFeed
|
||||
</Title>
|
||||
</Center>
|
||||
<Paper>
|
||||
<Title order={2} mb="md">
|
||||
<Trans>Password Recovery</Trans>
|
||||
</Title>
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
{message && (
|
||||
<Box mb="md">
|
||||
<Alert level="success" messages={[message]} />
|
||||
</Box>
|
||||
)}
|
||||
<form onSubmit={form.onSubmit(recoverPassword)}>
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="email"
|
||||
label={t`E-mail`}
|
||||
placeholder={t`E-mail`}
|
||||
{...form.getInputProps("email")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
|
||||
<Button type="submit" loading={recoverPasswordResult.status === "running"}>
|
||||
<Trans>Recover password</Trans>
|
||||
</Button>
|
||||
|
||||
<Center>
|
||||
<Group>
|
||||
<Anchor component={Link} to="/login">
|
||||
<Trans>Back to log in</Trans>
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Center>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
95
commafeed-client/src/pages/auth/RegistrationPage.tsx
Normal file
95
commafeed-client/src/pages/auth/RegistrationPage.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { t, Trans } from "@lingui/macro"
|
||||
import { Anchor, Box, Button, Center, Container, Group, Paper, PasswordInput, Stack, TextInput, Title } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { client, errorToStrings } from "app/client"
|
||||
import { redirectToRootCategory } from "app/slices/redirect"
|
||||
import { useAppDispatch, useAppSelector } from "app/store"
|
||||
import { RegistrationRequest } from "app/types"
|
||||
import { Alert } from "components/Alert"
|
||||
import { Logo } from "components/Logo"
|
||||
import { Link } from "react-router-dom"
|
||||
import useMutation from "use-mutation"
|
||||
|
||||
export function RegistrationPage() {
|
||||
const serverInfos = useAppSelector(state => state.server.serverInfos)
|
||||
const dispatch = useAppDispatch()
|
||||
|
||||
const form = useForm<RegistrationRequest>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
password: "",
|
||||
email: "",
|
||||
},
|
||||
})
|
||||
|
||||
const [register, registerResult] = useMutation(client.user.register, {
|
||||
onSuccess: () => {
|
||||
dispatch(redirectToRootCategory())
|
||||
},
|
||||
})
|
||||
const errors = errorToStrings(registerResult.error)
|
||||
|
||||
return (
|
||||
<Container size="xs">
|
||||
<Center my="xl">
|
||||
<Logo size={48} />
|
||||
<Title order={1} ml="md">
|
||||
CommaFeed
|
||||
</Title>
|
||||
</Center>
|
||||
<Paper>
|
||||
<Title order={2} mb="md">
|
||||
<Trans>Sign up</Trans>
|
||||
</Title>
|
||||
{serverInfos && !serverInfos.allowRegistrations && (
|
||||
<Box mb="md">
|
||||
<Alert messages={[t`Registrations are closed on this CommaFeed instance`]} />
|
||||
</Box>
|
||||
)}
|
||||
{serverInfos?.allowRegistrations && (
|
||||
<>
|
||||
{errors.length > 0 && (
|
||||
<Box mb="md">
|
||||
<Alert messages={errors} />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<form onSubmit={form.onSubmit(register)}>
|
||||
<Stack>
|
||||
<TextInput label="User Name" placeholder="User Name" {...form.getInputProps("name")} size="md" required />
|
||||
<TextInput
|
||||
type="email"
|
||||
label={t`E-mail address`}
|
||||
placeholder={t`E-mail address`}
|
||||
{...form.getInputProps("email")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
<PasswordInput
|
||||
label={t`Password`}
|
||||
placeholder={t`Password`}
|
||||
{...form.getInputProps("password")}
|
||||
size="md"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" loading={registerResult.status === "running"}>
|
||||
<Trans>Sign up</Trans>
|
||||
</Button>
|
||||
<Center>
|
||||
<Group>
|
||||
<Trans>
|
||||
<Box>Have an account?</Box>
|
||||
<Anchor component={Link} to="/login">
|
||||
Log in!
|
||||
</Anchor>
|
||||
</Trans>
|
||||
</Group>
|
||||
</Center>
|
||||
</Stack>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user