Squashed commit of the following:
commit 1e92328a0fc570fe9419ad5dbaaef77f7dc9ad2e Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 21:52:44 2024 +0800 yes, react it! commit 09fffff6139b4cecb81cb1444139f225e95e8917 Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 17:33:26 2024 +0800 actions to be done commit 1611b0b338cfd965d15f43fb10308bc56015895f Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 15:22:08 2024 +0800 modal needed. commit 88453e7382618fb6774ff1cc4c0f7045d4dfcf46 Author: Sense T <me@sense-t.eu.org> Date: Mon Apr 15 10:52:13 2024 +0800 Domain View done commit 8cedca27c79ca2ba69c8777dfcb6019799875e31 Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 14 21:24:14 2024 +0800 domain delete modal done commit 60cd00c0cad0774bae5b57bcfc4723a29d28d221 Author: Sense T <me@sense-t.eu.org> Date: Sun Apr 14 07:55:11 2024 +0800 1 commit 285853e988db6e6a6371135869da0129fd73afd7 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:29:43 2024 +0800 eslint commit 8f0ffbf744fd85a612daacd7bd6cbc45d58907d3 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:20:50 2024 +0800 f commit 9762b632225f185d83388e58d93ed49f62fe6b3f Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 17:08:37 2024 +0800 views, components to be done commit 321e5255f2b1e705844179dd910d5f5a1ae58298 Author: Sense T <me@sense-t.eu.org> Date: Sat Apr 13 14:29:04 2024 +0800 prepare for react
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
.n-card {
|
||||
.domain-info {
|
||||
width: 32vw;
|
||||
}
|
||||
text-align: left;
|
||||
}
|
||||
|
@@ -1,87 +1,102 @@
|
||||
import './DomainsView.css'
|
||||
import { Domain, useDomainStore } from '../stores/domains'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { App, Button, Card, Space, Spin } from 'antd'
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
|
||||
import { NSpin, NFlex, NCard, NButton, NIcon, NModalProvider, createDiscreteApi } from 'naive-ui'
|
||||
import { PlusSquare } from "@vicons/fa"
|
||||
import { type Domain, useDomainStore } from '@/stores/domains'
|
||||
import { getErrorInfo } from '@/apis/api'
|
||||
import DomainInfo from '@/components/domains/DomainInfo'
|
||||
import DomainOps from '@/components/domains/DomainOps'
|
||||
import DomainRemoveModal from '@/components/domains/DomainRemoveModal'
|
||||
import DomainEditModal from '@/components/domains/DomainEditModal'
|
||||
import { ref } from 'vue'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import DomainDeleteModal from '../components/domains/DomainDeleteModal'
|
||||
import DomainCard from '../components/domains/DomainCard'
|
||||
import DomainEditModal from '../components/domains/DomainEditModal'
|
||||
import { ResponseError, getErrorInfo } from '../api'
|
||||
|
||||
const domainStore = useDomainStore()
|
||||
const { notification } = createDiscreteApi(['notification'])
|
||||
const emptyDomain: Domain = { domain_name: '' }
|
||||
|
||||
const loading = ref(true);
|
||||
const removeModalShow = ref(false);
|
||||
const editModalShow = ref(false);
|
||||
const operationDomain = ref({} as Domain)
|
||||
export default function DomainsView() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [deleteModalShow, setDeleteModalShow] = useState(false)
|
||||
const [editModalShow, setEditModalShow] = useState(false)
|
||||
const [currentDomain, setCurrentDomain] = useState(emptyDomain)
|
||||
const { notification } = App.useApp()
|
||||
const domainStore = useDomainStore()
|
||||
const go = useNavigate()
|
||||
|
||||
function showRemoveModal(domain: Domain) {
|
||||
operationDomain.value = domain
|
||||
removeModalShow.value = true
|
||||
}
|
||||
|
||||
function showEditModal(domain: Domain) {
|
||||
operationDomain.value = domain
|
||||
editModalShow.value = true
|
||||
}
|
||||
|
||||
function addDomain() {
|
||||
const domain = {
|
||||
refresh_interval: 86400,
|
||||
retry_interval: 7200,
|
||||
expiry_period: 3600000,
|
||||
negative_ttl: 86400,
|
||||
serial_number: 1,
|
||||
} as Domain
|
||||
showEditModal(domain)
|
||||
}
|
||||
|
||||
function DomainsView() {
|
||||
try {
|
||||
domainStore.loadDomains()
|
||||
loading.value = false
|
||||
} catch (e) {
|
||||
const msg = getErrorInfo(e)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
function openDeleteModal(domain: Domain) {
|
||||
setCurrentDomain(domain)
|
||||
setDeleteModalShow(true)
|
||||
}
|
||||
|
||||
function closeDeleteModdal() {
|
||||
setDeleteModalShow(false)
|
||||
}
|
||||
|
||||
function openEditModal(domain: Domain) {
|
||||
setCurrentDomain(domain)
|
||||
setEditModalShow(true)
|
||||
}
|
||||
|
||||
function closeEditModal() {
|
||||
setEditModalShow(false)
|
||||
}
|
||||
|
||||
function newDomain() {
|
||||
openEditModal({
|
||||
domain_name: '',
|
||||
admin_email: '',
|
||||
main_dns: '',
|
||||
refresh_interval: 86400,
|
||||
retry_interval: 7200,
|
||||
expiry_period: 3600000,
|
||||
negative_ttl: 86400,
|
||||
serial_number: 1,
|
||||
})
|
||||
}
|
||||
|
||||
// called once only.
|
||||
useEffect(() => {
|
||||
domainStore.loadDomains().then(() => setLoading(false)).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
loading.value ? <NSpin size="large" /> :
|
||||
<NModalProvider>
|
||||
<NFlex vertical>
|
||||
loading ? <Spin size='large' /> :
|
||||
<>
|
||||
<Space direction="vertical" >
|
||||
{
|
||||
domainStore.domains.map((domain: Domain) => (
|
||||
<NCard title={domain.domain_name} key={domain.id} size='large' hoverable>
|
||||
{{
|
||||
default: () => <DomainInfo domain={domain} />,
|
||||
action: () => <DomainOps domain={domain} onRemoveDomain={showRemoveModal} onEditDomain={showEditModal} />
|
||||
}}
|
||||
|
||||
</NCard>
|
||||
domainStore.domains.map(e => (
|
||||
<DomainCard domain={e}
|
||||
onDeleteClick={() => openDeleteModal(e)}
|
||||
onRecordClick={() => go(`/records/${e.domain_name}`)}
|
||||
onEditClick={() => openEditModal(e)}
|
||||
key={e.id}
|
||||
/>
|
||||
))
|
||||
}
|
||||
|
||||
<NCard hoverable>
|
||||
<NButton block quaternary size="large" onClick={addDomain}>
|
||||
{{
|
||||
icon: () => <NIcon component={PlusSquare} depth={5} />
|
||||
}}
|
||||
</NButton>
|
||||
</NCard>
|
||||
</NFlex>
|
||||
<DomainRemoveModal show={removeModalShow.value} domain={operationDomain.value} onUpdate:show={(v: boolean) => removeModalShow.value = v} />
|
||||
<DomainEditModal show={editModalShow.value} domain={operationDomain.value} onUpdate:show={(v: boolean) => editModalShow.value = v} />
|
||||
</NModalProvider>
|
||||
<Card>
|
||||
<Button icon={<PlusOutlined className='icon' />}
|
||||
block type="text" onClick={newDomain} />
|
||||
</Card>
|
||||
</Space>
|
||||
<DomainDeleteModal open={deleteModalShow}
|
||||
onCancel={closeDeleteModdal}
|
||||
onOk={closeDeleteModdal}
|
||||
domain={currentDomain}
|
||||
removeDomain={domainStore.removeDomain}
|
||||
/>
|
||||
<DomainEditModal open={editModalShow}
|
||||
onCancel={closeEditModal}
|
||||
onOk={closeEditModal}
|
||||
domain={currentDomain}
|
||||
editDomain={domainStore.updateDomain}
|
||||
createDomain={domainStore.addDomain}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
DomainsView.displayName = 'DomainsView'
|
||||
|
||||
export default DomainsView
|
||||
}
|
@@ -1,7 +1,38 @@
|
||||
div#records {
|
||||
position: absolute;
|
||||
.records-layout {
|
||||
position: fixed;
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
height: 100vh;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
padding-top: 1em;
|
||||
}
|
||||
|
||||
.right {
|
||||
position:absolute ;
|
||||
right: 64px;
|
||||
}
|
||||
|
||||
.records-layout-header {
|
||||
background-color: var(--records-layout-header-bgcolor);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.display-as-inline {
|
||||
display:inline-block;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
h1.title {
|
||||
font-weight:normal;
|
||||
font-size: 14pt;
|
||||
}
|
||||
|
||||
h2.subtitle {
|
||||
font-weight:normal;
|
||||
font-size: 12pt;
|
||||
}
|
||||
|
@@ -1,250 +1,119 @@
|
||||
import { LeftOutlined, PlusOutlined, SearchOutlined } from "@ant-design/icons"
|
||||
import { App, Button, Flex, Input, Layout, Spin, Table, Typography, theme } from "antd"
|
||||
import { Params, useLoaderData, useNavigate } from "react-router-dom"
|
||||
import './RecordsView.css'
|
||||
import i18n from '../locale'
|
||||
import { useEffect, useState } from "react"
|
||||
import { type Record, RecordT, useRecordStore, RecordTypes } from "../stores/records"
|
||||
import { ResponseError, getErrorInfo } from "../api"
|
||||
import RecordOps from "../components/records/RecordOps"
|
||||
import RecordEditModal from "../components/records/RecordEditModal"
|
||||
|
||||
import {
|
||||
NSpin, NPageHeader,
|
||||
NFlex, NButton, NIcon, NGrid, NGi,
|
||||
NStatistic, NDataTable, NInput,
|
||||
NModalProvider,
|
||||
createDiscreteApi
|
||||
} from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { useRecordStore, type Record, type SOARecord, RecordTypes, type ARecord } from '@/stores/records'
|
||||
import { getErrorInfo } from '@/apis/api'
|
||||
import { PlusSquare, RedoAlt, CheckCircle, Clock, Cogs, Search } from '@vicons/fa'
|
||||
import router from '@/router';
|
||||
import RecordOps from '@/components/records/RecordOps'
|
||||
import RecordEditModal from '@/components/records/RecordEditModal'
|
||||
import i18n from '@/locale/i18n'
|
||||
import { ref } from 'vue'
|
||||
const { t } = i18n
|
||||
const emptyRecord: Record<RecordT> = {} as Record
|
||||
|
||||
const { t } = i18n.global
|
||||
export default function RecordsView() {
|
||||
const { domain } = useLoaderData() as Params<string>
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchText, setSearchText] = useState<string>('')
|
||||
const [editModalShow, setEditModalShow] = useState(false)
|
||||
const [currentRecord, setCurrentRecord] = useState<Record>(emptyRecord)
|
||||
const { notification } = App.useApp()
|
||||
const {
|
||||
token: { colorBgContainer, borderRadiusLG },
|
||||
} = theme.useToken();
|
||||
const go = useNavigate()
|
||||
const recordStore = useRecordStore()
|
||||
|
||||
type Props = {
|
||||
domain: string
|
||||
}
|
||||
useEffect(() => {
|
||||
if (domain)
|
||||
recordStore.loadRecords(domain).then(() => setLoading(false)).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}, [domain])
|
||||
|
||||
const recordStore = useRecordStore()
|
||||
const { notification } = createDiscreteApi(['notification'])
|
||||
const editModalShow = ref(false)
|
||||
const editingRecord = ref<Record>({} as Record)
|
||||
const loading = ref(true);
|
||||
const records = ref<Record[] | undefined>([] as Record[]);
|
||||
const soa = ref<SOARecord>({} as SOARecord)
|
||||
const reloadRecords = () => records.value = recordStore.records?.filter(e => e.record_type !== RecordTypes.RecordTypeSOA)
|
||||
|
||||
async function refreshRecords(domain: string) {
|
||||
try {
|
||||
await recordStore.loadRecords(domain)
|
||||
reloadRecords()
|
||||
soa.value = recordStore.records?.find(e => e.record_type === RecordTypes.RecordTypeSOA)?.content as SOARecord
|
||||
} catch (err) {
|
||||
const msg = getErrorInfo(err)
|
||||
notification.error(msg)
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false;
|
||||
function closeEditModal() {
|
||||
setCurrentRecord(emptyRecord)
|
||||
setEditModalShow(false)
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/domains')
|
||||
}
|
||||
|
||||
function searchRecord(value: string) {
|
||||
if (value.length > 0) {
|
||||
records.value = recordStore.records?.
|
||||
filter(e => e.record_type !== RecordTypes.RecordTypeSOA).
|
||||
filter(e => !!~e.name.indexOf(value))
|
||||
} else {
|
||||
records.value = recordStore.records?.
|
||||
filter(e => e.record_type !== RecordTypes.RecordTypeSOA)
|
||||
function openEditModal(record: Record) {
|
||||
setCurrentRecord(record)
|
||||
setEditModalShow(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord(domain: string, record: Record) {
|
||||
try {
|
||||
await recordStore.removeRecord(domain, record)
|
||||
reloadRecords()
|
||||
} catch (err) {
|
||||
const msg = getErrorInfo(err)
|
||||
notification.error(msg)
|
||||
console.error(err)
|
||||
function newRecord() {
|
||||
openEditModal({
|
||||
zone: `${domain}.`,
|
||||
name: '',
|
||||
record_type: RecordTypes.RecordTypeA,
|
||||
ttl: 600
|
||||
} as Record)
|
||||
}
|
||||
}
|
||||
|
||||
function showEditing(domain: string, record: Record) {
|
||||
editModalShow.value = true
|
||||
editingRecord.value = record
|
||||
}
|
||||
|
||||
function newRecord(domain: string) {
|
||||
showEditing(domain, {
|
||||
zone: `${domain}.`,
|
||||
ttl: 500,
|
||||
record_type: RecordTypes.RecordTypeA,
|
||||
content: {
|
||||
ip: ''
|
||||
} as ARecord
|
||||
} as Record)
|
||||
}
|
||||
|
||||
const generateColumns = (domain: string) => [
|
||||
{
|
||||
key: 'no',
|
||||
title: '#',
|
||||
render(_, index) {
|
||||
return index + 1
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
title: t("records.name"),
|
||||
},
|
||||
{
|
||||
key: 'record_type',
|
||||
title: t('records.recordType')
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
title: t('records.content'),
|
||||
render(row: Record) {
|
||||
return Object.entries(row.content).map(i => i[1]).join(" ")
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'ttl',
|
||||
title: 'TTL (s)'
|
||||
},
|
||||
{
|
||||
key: '',
|
||||
render(row: Record) {
|
||||
return <RecordOps record={row} domain={domain} onRecordDelete={deleteRecord} onEditRecord={showEditing} />
|
||||
}
|
||||
}
|
||||
] as DataTableColumns<Record>
|
||||
|
||||
const statRefresh = () => (
|
||||
<NGi>
|
||||
<NStatistic value={soa.value.refresh}>
|
||||
{{
|
||||
suffix: () => 's',
|
||||
label: () => (
|
||||
<>
|
||||
<NIcon component={RedoAlt} style={{ transform: 'translateY(2px)' }} />
|
||||
<span>{t('records.refresh')}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</NStatistic>
|
||||
</NGi>
|
||||
)
|
||||
|
||||
const statRetry = () => (
|
||||
<NGi>
|
||||
<NStatistic value={soa.value.retry}>
|
||||
{{
|
||||
suffix: () => 's',
|
||||
label: () => (
|
||||
<>
|
||||
<NIcon component={CheckCircle} style={{ transform: 'translateY(2px)' }} />
|
||||
<span>{t('records.retry')}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</NStatistic>
|
||||
</NGi>
|
||||
)
|
||||
|
||||
const statExpire = () => (
|
||||
<NGi>
|
||||
<NStatistic value={soa.value.expire}>
|
||||
{{
|
||||
suffix: () => 's',
|
||||
label: () => (
|
||||
<>
|
||||
<NIcon component={Clock} style={{ transform: 'translateY(2px)' }} />
|
||||
<span>{t('records.expire')}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</NStatistic>
|
||||
</NGi>
|
||||
)
|
||||
|
||||
const statTTL = () => (
|
||||
<NGi>
|
||||
<NStatistic value={soa.value.minttl}>
|
||||
{{
|
||||
suffix: () => 's',
|
||||
label: () => (
|
||||
<>
|
||||
<NIcon component={Cogs} style={{ transform: 'translateY(2px)' }} />
|
||||
<span>{t('records.ttl')}</span>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</NStatistic>
|
||||
</NGi>
|
||||
)
|
||||
|
||||
function recordsViewBodyHeaderExtra() {
|
||||
return (
|
||||
<NGrid cols={4} >
|
||||
<statRefresh />
|
||||
<statRetry />
|
||||
<statExpire />
|
||||
<statTTL />
|
||||
</NGrid>
|
||||
)
|
||||
}
|
||||
|
||||
function recordsViewBody({ domain }: Props) {
|
||||
const columns = generateColumns(domain)
|
||||
return (
|
||||
<NModalProvider>
|
||||
<NPageHeader title={t('domains.dnsRecord')} subtitle={domain} onBack={goBack}>
|
||||
{{
|
||||
extra: () => (
|
||||
<NFlex wrap={false} justify="end" inline>
|
||||
<NButton type="primary" onClick={() => newRecord(domain)}>
|
||||
{{
|
||||
icon: () => <NIcon component={PlusSquare} />,
|
||||
default: () => t('common.add')
|
||||
}}
|
||||
</NButton>
|
||||
<NInput placeholder={t('records.search')} onUpdate:value={searchRecord} clearable>
|
||||
{{
|
||||
prefix: () => <NIcon component={Search} />
|
||||
}}
|
||||
</NInput>
|
||||
</NFlex>
|
||||
),
|
||||
default: () => <recordsViewBodyHeaderExtra />
|
||||
}}
|
||||
</NPageHeader>
|
||||
<br />
|
||||
<NDataTable data={records.value} columns={columns} pagination={{ pageSize: 20 }} />
|
||||
</NModalProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function RecordsView({ domain }: Props) {
|
||||
try {
|
||||
refreshRecords(domain)
|
||||
} catch (err) {
|
||||
const msg = getErrorInfo(err)
|
||||
notification.error(msg)
|
||||
console.error(err)
|
||||
}
|
||||
return (
|
||||
<div id='records'>
|
||||
<RecordEditModal show={editModalShow.value} domain={domain} record={editingRecord.value} onReloadRecords={reloadRecords} onUpdate:show={(v: boolean) => editModalShow.value = v} />
|
||||
<>
|
||||
{
|
||||
loading.value ? <NSpin size='large' /> : <recordsViewBody domain={domain} />
|
||||
loading ? <Spin size='large' /> :
|
||||
<>
|
||||
<Layout className="records-layout">
|
||||
<Layout.Header className="records-layout-header">
|
||||
<Flex align='center' className="toolbar">
|
||||
<Flex align="center" gap='small'>
|
||||
<Button onClick={() => go('/domains')} type="text" icon={<LeftOutlined />} />
|
||||
<Typography.Title level={1} className="display-as-inline title">{t('domains.dnsRecord')}</Typography.Title>
|
||||
<Typography.Title level={2} type='secondary' className="display-as-inline subtitle">{domain}</Typography.Title>
|
||||
</Flex>
|
||||
<Flex align="center" gap='small' className="right">
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={newRecord}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
<Input prefix={<SearchOutlined />} placeholder={t('records.search')} onChange={v => setSearchText(v.target.value)} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Layout.Header>
|
||||
<Layout.Content style={{
|
||||
margin: 24,
|
||||
borderRadius: borderRadiusLG,
|
||||
minHeight: 480,
|
||||
background: colorBgContainer,
|
||||
}}>
|
||||
<Table<Record<RecordT>>
|
||||
dataSource={recordStore.records
|
||||
.filter(i => i.record_type !== RecordTypes.RecordTypeSOA)
|
||||
.filter(i => i.name?.includes(searchText) || Object.entries(i.content as RecordT).map(i => i[1]).join(" ").includes(searchText))
|
||||
}
|
||||
pagination={{ defaultPageSize: 20 }}
|
||||
rowKey={e => `${e.id}`}
|
||||
>
|
||||
<Table.Column<Record<RecordT>> title='#' render={(_v, _r, index) => index + 1} />
|
||||
<Table.Column<Record<RecordT>> title={t("records.name")} dataIndex='name' key='name' />
|
||||
<Table.Column<Record<RecordT>> title={t('records.recordType')} dataIndex='record_type' key='record_type' />
|
||||
<Table.Column<Record<RecordT>> title={t('records.content')} render={(v) => Object.entries(v.content).map(i => i[1]).join(" ")} />
|
||||
<Table.Column<Record<RecordT>> title='TTL' key='ttl' dataIndex='ttl' />
|
||||
<Table.Column<Record<RecordT>> key='op' render={(v: Record) =>
|
||||
<RecordOps
|
||||
onDelete={() => {
|
||||
if (domain)
|
||||
recordStore.removeRecord(domain, v).catch(e => {
|
||||
const msg = getErrorInfo(e as ResponseError)
|
||||
notification.error(msg)
|
||||
console.error(e)
|
||||
})
|
||||
}} onEdit={() => openEditModal(v)}
|
||||
/>
|
||||
} />
|
||||
</Table>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
<RecordEditModal open={editModalShow} onCancel={closeEditModal}
|
||||
onOk={closeEditModal} record={currentRecord}
|
||||
editRecord={v => recordStore.updateRecord(domain!, v)}
|
||||
createRecord={v => recordStore.addRecord(domain!, v)} />
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
RecordsView.displayName = 'RecordsView'
|
||||
export default RecordsView
|
||||
}
|
Reference in New Issue
Block a user