Merge branch 'feature/various-ux' into develop

This commit is contained in:
Maël Gangloff
2025-11-02 13:45:07 +01:00
10 changed files with 298 additions and 88 deletions

View File

@@ -1,4 +1,4 @@
import {Button, ConfigProvider, Drawer, Flex, FloatButton, Layout, theme, Tooltip, Typography} from 'antd' import {Button, ConfigProvider, Drawer, Flex, Layout, theme, Typography} from 'antd'
import {Link, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom' import {Link, Navigate, Route, Routes, useLocation, useNavigate} from 'react-router-dom'
import TextPage from './pages/TextPage' import TextPage from './pages/TextPage'
import DomainSearchPage from './pages/search/DomainSearchPage' import DomainSearchPage from './pages/search/DomainSearchPage'
@@ -16,7 +16,7 @@ import NotFoundPage from './pages/NotFoundPage'
import useBreakpoint from './hooks/useBreakpoint' import useBreakpoint from './hooks/useBreakpoint'
import {Sider} from './components/Sider' import {Sider} from './components/Sider'
import {jt, t} from 'ttag' import {jt, t} from 'ttag'
import {BugOutlined, InfoCircleOutlined, MergeOutlined, MenuOutlined} from '@ant-design/icons' import {MenuOutlined} from '@ant-design/icons'
import TrackedDomainPage from './pages/tracking/TrackedDomainPage' import TrackedDomainPage from './pages/tracking/TrackedDomainPage'
import IcannRegistrarPage from "./pages/infrastructure/IcannRegistrarPage" import IcannRegistrarPage from "./pages/infrastructure/IcannRegistrarPage"
@@ -165,9 +165,24 @@ export default function App(): React.ReactElement {
target='_blank' target='_blank'
href='https://github.com/maelgangloff/domain-watchdog/wiki' href='https://github.com/maelgangloff/domain-watchdog/wiki'
> >
<Button <Button type='text'>
type='text' {t`Documentation`}
>{t`Documentation`} </Button>
</Typography.Link>
<Typography.Link
target='_blank'
href={PROJECT_LINK}
>
<Button type='text'>
{t`Source code`}
</Button>
</Typography.Link>
<Typography.Link
target='_blank'
href={PROJECT_LINK + '/issues'}
>
<Button type='text'>
{t`Submit an issue`}
</Button> </Button>
</Typography.Link> </Typography.Link>
</Flex> </Flex>
@@ -176,22 +191,6 @@ export default function App(): React.ReactElement {
</Typography.Paragraph> </Typography.Paragraph>
</Layout.Footer> </Layout.Footer>
</Layout> </Layout>
<FloatButton.Group
trigger='hover'
style={{
position: 'fixed',
insetInlineEnd: (100 - 40) / 2,
bottom: 100 - 40 / 2
}}
icon={<InfoCircleOutlined/>}
>
<Tooltip title={t`Official git repository`} placement='left'>
<FloatButton icon={<MergeOutlined/>} target='_blank' href={PROJECT_LINK}/>
</Tooltip>
<Tooltip title={t`Submit an issue`} placement='left'>
<FloatButton icon={<BugOutlined/>} target='_blank' href={PROJECT_LINK + '/issues'}/>
</Tooltip>
</FloatButton.Group>
</Layout> </Layout>
</AuthenticatedContext.Provider> </AuthenticatedContext.Provider>
</ConfigProvider> </ConfigProvider>

View File

@@ -0,0 +1,101 @@
import React, {useEffect, useState} from "react"
import type {ModalProps} from "antd"
import {Tag, Tooltip} from "antd"
import {Flex, Modal, Select, Typography} from "antd"
import type {Domain, Watchlist} from "../../../utils/api"
import {getWatchlists} from "../../../utils/api"
import {t} from 'ttag'
import {DomainToTag} from "../../../utils/functions/DomainToTag"
import {EllipsisOutlined} from '@ant-design/icons'
const MAX_DOMAIN_TAGS = 25
function WatchlistOption({watchlist}: {watchlist: Watchlist}) {
let domains = watchlist.domains
let rest: Domain[]|undefined = undefined
if (domains.length > MAX_DOMAIN_TAGS) {
rest = domains.slice(MAX_DOMAIN_TAGS)
domains = domains.slice(0, MAX_DOMAIN_TAGS)
}
return <Flex vertical>
<Typography.Text strong>{watchlist.name}</Typography.Text>
<Flex wrap gap='4px'>
{domains.map(d => <DomainToTag link={false} domain={d} key={d.ldhName} />)}
{rest
&& <Tooltip title={rest.map(d => <DomainToTag link={false} domain={d} key={d.ldhName} />)}>
<Tag icon={<EllipsisOutlined/>} color='processing'>
{t`${rest.length} more`}
</Tag>
</Tooltip>
}
</Flex>
</Flex>
}
interface WatchlistSelectionModalProps {
onFinish: (watchlist: Watchlist) => Promise<void>|void
description?: string
open?: boolean
modalProps?: Partial<ModalProps>
}
export default function WatchlistSelectionModal(props: WatchlistSelectionModalProps) {
const [watchlists, setWatchlists] = useState<Watchlist[] | undefined>()
const [selectedWatchlist, setSelectedWatchlist] = useState<Watchlist | undefined>()
const [validationLoading, setValidationLoading] = useState(false)
useEffect(() => {
if (props.open && !watchlists) {
getWatchlists().then(list => setWatchlists(list["hydra:member"]))
}
}, [props.open])
const onFinish = () => {
const promise = props.onFinish(selectedWatchlist as Watchlist)
if (promise) {
setValidationLoading(true)
promise.finally(() => {
setSelectedWatchlist(undefined)
setValidationLoading(false)
})
} else {
setSelectedWatchlist(undefined)
}
}
return <Modal
open={props.open}
onOk={onFinish}
okButtonProps={{
disabled: !selectedWatchlist,
loading: validationLoading,
}}
{...props.modalProps ?? {}}
>
<Flex vertical>
<Typography.Paragraph>
{
props.description
|| t`Select one of your available Watchlists`
}
</Typography.Paragraph>
<Select
placeholder={t`Watchlist`}
style={{width: '100%'}}
onChange={(_, option) => setSelectedWatchlist(option as Watchlist)}
options={watchlists}
value={selectedWatchlist?.token}
fieldNames={{
label: 'name',
value: 'token',
}}
loading={!watchlists}
status={selectedWatchlist ? '' : 'error'}
optionRender={(watchlist) => <WatchlistOption watchlist={watchlist.data}/>}
/>
</Flex>
</Modal>
}

View File

@@ -1,8 +1,10 @@
import React, {useEffect, useState} from 'react' import React, {useEffect, useState} from 'react'
import type { FormProps} from 'antd' import type { FormProps} from 'antd'
import {FloatButton} from 'antd'
import {Empty, Flex, message, Skeleton} from 'antd' import {Empty, Flex, message, Skeleton} from 'antd'
import type {Domain} from '../../utils/api' import type {Domain, Watchlist} from '../../utils/api'
import { getDomain} from '../../utils/api' import {addDomainToWatchlist} from '../../utils/api'
import {getDomain} from '../../utils/api'
import type {AxiosError} from 'axios' import type {AxiosError} from 'axios'
import {t} from 'ttag' import {t} from 'ttag'
import type { FieldType} from '../../components/search/DomainSearchBar' import type { FieldType} from '../../components/search/DomainSearchBar'
@@ -10,17 +12,19 @@ import {DomainSearchBar} from '../../components/search/DomainSearchBar'
import {DomainResult} from '../../components/search/DomainResult' import {DomainResult} from '../../components/search/DomainResult'
import {showErrorAPI} from '../../utils/functions/showErrorAPI' import {showErrorAPI} from '../../utils/functions/showErrorAPI'
import {useNavigate, useParams} from 'react-router-dom' import {useNavigate, useParams} from 'react-router-dom'
import {PlusOutlined} from '@ant-design/icons'
import WatchlistSelectionModal from '../../components/tracking/watchlist/WatchlistSelectionModal'
export default function DomainSearchPage() { export default function DomainSearchPage() {
const {query} = useParams() const {query} = useParams()
const [domain, setDomain] = useState<Domain | null>() const [domain, setDomain] = useState<Domain | null>()
const [loading, setLoading] = useState<boolean>(false) const domainLdhName = domain?.ldhName
const [loading, setLoading] = useState(false)
const [addToWatchlistModal, setAddToWatchlistModal] = useState(false)
const [messageApi, contextHolder] = message.useMessage() const [messageApi, contextHolder] = message.useMessage()
const navigate = useNavigate() const navigate = useNavigate()
const onFinish: FormProps<FieldType>['onFinish'] = (values) => { const onFinish: FormProps<FieldType>['onFinish'] = (values) => {
navigate('/search/domain/' + values.ldhName) navigate('/search/domain/' + values.ldhName)
@@ -41,7 +45,18 @@ export default function DomainSearchPage() {
onFinish({ldhName: query, isRefreshForced: false}) onFinish({ldhName: query, isRefreshForced: false})
}, []) }, [])
return ( const addToWatchlist = async (watchlist: Watchlist) => {
await addDomainToWatchlist(watchlist, domain!.ldhName).then(() => {
setAddToWatchlistModal(false)
const ldhName = domain?.ldhName
messageApi.success(t`${ldhName} added to ${watchlist.name}`)
}).catch((e: AxiosError) => {
showErrorAPI(e, messageApi)
})
}
return <>
<Flex gap='middle' align='center' justify='center' vertical> <Flex gap='middle' align='center' justify='center' vertical>
{contextHolder} {contextHolder}
<DomainSearchBar initialValue={query} onFinish={onFinish}/> <DomainSearchBar initialValue={query} onFinish={onFinish}/>
@@ -57,5 +72,29 @@ export default function DomainSearchPage() {
} }
</Skeleton> </Skeleton>
</Flex> </Flex>
) {domain
&& <FloatButton
style={{
position: 'fixed',
insetInlineEnd: (100 - 40) / 2,
bottom: 100 - 40 / 2
}}
tooltip={t`Add to Watchlist`}
type="primary"
icon={<PlusOutlined/>}
onClick={() => setAddToWatchlistModal(true)}
/>
}
<WatchlistSelectionModal
open={addToWatchlistModal}
onFinish={addToWatchlist}
modalProps={{
title: t`Add ${domainLdhName} to a Watchlist`,
onCancel: () => setAddToWatchlistModal(false),
onClose: () => setAddToWatchlistModal(false),
cancelText: t`Cancel`,
okText: t`Add`
}}
/>
</>
} }

View File

@@ -44,6 +44,13 @@ export async function patchWatchlist(token: string, watchlist: Partial<Watchlist
return response.data return response.data
} }
export async function addDomainToWatchlist(watchlist: Watchlist, ldhName: string) {
const domains = watchlist.domains.map(d => '/api/domains/' + d.ldhName)
domains.push('/api/domains/' + ldhName)
return patchWatchlist(watchlist.token, {domains})
}
export async function deleteWatchlist(token: string): Promise<void> { export async function deleteWatchlist(token: string): Promise<void> {
await request({ await request({
method: 'DELETE', method: 'DELETE',

View File

@@ -6,32 +6,38 @@ import React from 'react'
import type {Event} from "../api" import type {Event} from "../api"
import {t} from "ttag" import {t} from "ttag"
export function DomainToTag({domain}: { domain: { ldhName: string, deleted: boolean, status: string[], events?: Event[] } }) { export function DomainToTag({domain, link}: { domain: { ldhName: string, deleted: boolean, status: string[], events?: Event[] }, link?: boolean }) {
return ( const tag = <Badge dot={domain.events?.find(e =>
<Link to={'/search/domain/' + domain.ldhName}> e.action === 'last changed' &&
<Badge dot={domain.events?.find(e => !e.deleted &&
e.action === 'last changed' && ((new Date().getTime() - new Date(e.date).getTime()) < 7*24*60*60*1e3)
!e.deleted && ) !== undefined} color='blue' title={t`The domain name was updated less than a week ago.`}>
((new Date().getTime() - new Date(e.date).getTime()) < 7*24*60*60*1e3) <Tag
) !== undefined} color='blue' title={t`The domain name was updated less than a week ago.`}> color={
<Tag domain.deleted
color={ ? 'magenta'
domain.deleted : domain.status.includes('redemption period')
? 'magenta' ? 'yellow'
: domain.status.includes('redemption period') : domain.status.includes('pending delete') ? 'volcano' : 'default'
? 'yellow' }
: domain.status.includes('pending delete') ? 'volcano' : 'default' icon={
} domain.deleted
icon={ ? <DeleteOutlined/>
domain.deleted : domain.status.includes('redemption period')
? <DeleteOutlined/> ? <ExclamationCircleOutlined/>
: domain.status.includes('redemption period') : domain.status.includes('pending delete') ? <DeleteOutlined/> : null
? <ExclamationCircleOutlined/> }
: domain.status.includes('pending delete') ? <DeleteOutlined/> : null >{punycode.toUnicode(domain.ldhName)}
} </Tag>
>{punycode.toUnicode(domain.ldhName)} </Badge>
</Tag>
</Badge> if (link ?? true) {
</Link> return (
) <Link to={'/search/domain/' + domain.ldhName}>
{tag}
</Link>
)
} else {
return tag
}
} }

View File

@@ -11,7 +11,6 @@ use App\Repository\DomainRepository;
use App\Repository\WatchlistRepository; use App\Repository\WatchlistRepository;
use App\Service\CalendarService; use App\Service\CalendarService;
use App\Service\RDAPService; use App\Service\RDAPService;
use Doctrine\Common\Collections\Collection;
use Eluceo\iCal\Domain\Entity\Calendar; use Eluceo\iCal\Domain\Entity\Calendar;
use Eluceo\iCal\Presentation\Component\Property; use Eluceo\iCal\Presentation\Component\Property;
use Eluceo\iCal\Presentation\Component\Property\Value\TextValue; use Eluceo\iCal\Presentation\Component\Property\Value\TextValue;
@@ -36,23 +35,6 @@ class WatchlistController extends AbstractController
) { ) {
} }
#[Route(
path: '/api/watchlists',
name: 'watchlist_get_all_mine',
defaults: [
'_api_resource_class' => Watchlist::class,
'_api_operation_name' => 'get_all_mine',
],
methods: ['GET']
)]
public function getWatchlists(): Collection
{
/** @var User $user */
$user = $this->getUser();
return $user->getWatchlists();
}
/** /**
* @throws ParseException * @throws ParseException
* @throws EofException * @throws EofException

View File

@@ -10,6 +10,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put; use ApiPlatform\Metadata\Put;
use App\Repository\WatchlistRepository; use App\Repository\WatchlistRepository;
use App\State\MyWatchlistsProvider;
use App\State\WatchlistUpdateProcessor; use App\State\WatchlistUpdateProcessor;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -25,7 +26,6 @@ use Symfony\Component\Validator\Constraints as Assert;
shortName: 'Watchlist', shortName: 'Watchlist',
operations: [ operations: [
new GetCollection( new GetCollection(
routeName: 'watchlist_get_all_mine',
normalizationContext: [ normalizationContext: [
'groups' => [ 'groups' => [
'watchlist:list', 'watchlist:list',
@@ -34,6 +34,7 @@ use Symfony\Component\Validator\Constraints as Assert;
], ],
], ],
name: 'get_all_mine', name: 'get_all_mine',
provider: MyWatchlistsProvider::class,
), ),
new GetCollection( new GetCollection(
uriTemplate: '/tracked', uriTemplate: '/tracked',

View File

@@ -2,6 +2,7 @@
namespace App\Repository; namespace App\Repository;
use App\Entity\User;
use App\Entity\Watchlist; use App\Entity\Watchlist;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ManagerRegistry;
@@ -33,6 +34,24 @@ class WatchlistRepository extends ServiceEntityRepository
->getQuery()->execute(); ->getQuery()->execute();
} }
/**
* @return Watchlist[]
*/
public function fetchWatchlistsForUser(User $user): array
{
return $this->createQueryBuilder('w')
->addSelect('d')
->addSelect('e')
->addSelect('p')
->leftJoin('w.domains', 'd')
->leftJoin('d.events', 'e')
->leftJoin('d.domainPurchases', 'p')
->where('w.user = :user')
->setParameter('user', $user)
->getQuery()
->getResult();
}
// /** // /**
// * @return Watchlist[] Returns an array of Watchlist objects // * @return Watchlist[] Returns an array of Watchlist objects
// */ // */

View File

@@ -0,0 +1,24 @@
<?php
namespace App\State;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Entity\User;
use App\Repository\WatchlistRepository;
use Symfony\Bundle\SecurityBundle\Security;
readonly class MyWatchlistsProvider implements ProviderInterface
{
public function __construct(private Security $security, private WatchlistRepository $watchlistRepository)
{
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null
{
/** @var User $user */
$user = $this->security->getUser();
return $this->watchlistRepository->fetchWatchlistsForUser($user);
}
}

View File

@@ -15,25 +15,25 @@ msgstr ""
msgid "FAQ" msgid "FAQ"
msgstr "" msgstr ""
#: assets/App.tsx:170 #: assets/App.tsx:169
msgid "Documentation" msgid "Documentation"
msgstr "" msgstr ""
#: assets/App.tsx:175 #: assets/App.tsx:177
msgid "Source code"
msgstr ""
#: assets/App.tsx:185
msgid "Submit an issue"
msgstr ""
#: assets/App.tsx:190
#, javascript-format #, javascript-format
msgid "" msgid ""
"${ ProjectLink } is an open source project distributed under the ${ " "${ ProjectLink } is an open source project distributed under the ${ "
"LicenseLink } license." "LicenseLink } license."
msgstr "" msgstr ""
#: assets/App.tsx:188
msgid "Official git repository"
msgstr ""
#: assets/App.tsx:191
msgid "Submit an issue"
msgstr ""
#: assets/components/LoginForm.tsx:53 #: assets/components/LoginForm.tsx:53
#: assets/components/RegisterForm.tsx:37 #: assets/components/RegisterForm.tsx:37
msgid "Email address" msgid "Email address"
@@ -480,6 +480,7 @@ msgid "This Watchlist is not linked to a Connector."
msgstr "" msgstr ""
#: assets/components/tracking/watchlist/WatchlistCard.tsx:59 #: assets/components/tracking/watchlist/WatchlistCard.tsx:59
#: assets/components/tracking/watchlist/WatchlistSelectionModal.tsx:86
msgid "Watchlist" msgid "Watchlist"
msgstr "" msgstr ""
@@ -564,6 +565,15 @@ msgstr ""
msgid "Reset" msgid "Reset"
msgstr "" msgstr ""
#: assets/components/tracking/watchlist/WatchlistSelectionModal.tsx:29
#, javascript-format
msgid "${ rest.length } more"
msgstr ""
#: assets/components/tracking/watchlist/WatchlistSelectionModal.tsx:82
msgid "Select one of your available Watchlists"
msgstr ""
#: assets/pages/infrastructure/IcannRegistrarPage.tsx:45 #: assets/pages/infrastructure/IcannRegistrarPage.tsx:45
msgid "ID" msgid "ID"
msgstr "" msgstr ""
@@ -688,16 +698,38 @@ msgstr ""
msgid "Sorry, the page you visited does not exist." msgid "Sorry, the page you visited does not exist."
msgstr "" msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:32 #: assets/pages/search/DomainSearchPage.tsx:36
msgid "Found !" msgid "Found !"
msgstr "" msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:55 #: assets/pages/search/DomainSearchPage.tsx:53
#, javascript-format
msgid "${ ldhName } added to ${ watchlist.name }"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:70
msgid "" msgid ""
"Although the domain exists in my database, it has been deleted from the " "Although the domain exists in my database, it has been deleted from the "
"WHOIS by its registrar." "WHOIS by its registrar."
msgstr "" msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:82
msgid "Add to Watchlist"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:92
#, javascript-format
msgid "Add ${ domainLdhName } to a Watchlist"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:95
msgid "Cancel"
msgstr ""
#: assets/pages/search/DomainSearchPage.tsx:96
msgid "Add"
msgstr ""
#: assets/pages/StatisticsPage.tsx:33 #: assets/pages/StatisticsPage.tsx:33
msgid "RDAP queries" msgid "RDAP queries"
msgstr "" msgstr ""
@@ -760,7 +792,7 @@ msgstr ""
msgid "Roles" msgid "Roles"
msgstr "" msgstr ""
#: assets/utils/functions/DomainToTag.tsx:16 #: assets/utils/functions/DomainToTag.tsx:14
msgid "The domain name was updated less than a week ago." msgid "The domain name was updated less than a week ago."
msgstr "" msgstr ""