mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
fredy goes multilingual 🇩🇪 🇺🇸
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
|
||||
import { useTranslation } from '../services/i18n/i18n.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -12,11 +13,14 @@ const ListingDeletionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
title = 'Delete Listings',
|
||||
title,
|
||||
showOptions = true,
|
||||
message = 'How would you like to delete the selected listing(s)?',
|
||||
message,
|
||||
defaultDeleteType = 'soft',
|
||||
}) => {
|
||||
const t = useTranslation();
|
||||
const resolvedTitle = title ?? t('listing.deletion.title');
|
||||
const resolvedMessage = message ?? t('listing.deletion.message');
|
||||
const [deleteType, setDeleteType] = useState('soft');
|
||||
const [remember, setRemember] = useState(false);
|
||||
|
||||
@@ -37,47 +41,41 @@ const ListingDeletionModal = ({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={title}
|
||||
title={resolvedTitle}
|
||||
visible={visible}
|
||||
onOk={handleOk}
|
||||
onCancel={onCancel}
|
||||
okText="Confirm"
|
||||
cancelText="Cancel"
|
||||
okText={t('listing.deletion.confirm')}
|
||||
cancelText={t('listing.deletion.cancel')}
|
||||
style={{ maxWidth: '500px' }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text>{message}</Text>
|
||||
<Text>{resolvedMessage}</Text>
|
||||
</div>
|
||||
{showOptions && (
|
||||
<>
|
||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||
<Text strong>{t('listing.deletion.softLabel')}</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||
scraping session.
|
||||
</Text>
|
||||
<Text type="secondary">{t('listing.deletion.softDescription')}</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||
<div style={{ marginLeft: 8 }}>
|
||||
<Text strong>Remove from database (Hard Delete)</Text>
|
||||
<Text strong>{t('listing.deletion.hardLabel')}</Text>
|
||||
<br />
|
||||
<Text type="secondary">
|
||||
Listings are completely removed from the database.
|
||||
{t('listing.deletion.hardDescription')}
|
||||
<br />
|
||||
<Text type="warning">
|
||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||
previously found.
|
||||
</Text>
|
||||
<Text type="warning">{t('listing.deletion.hardConsequence')}</Text>
|
||||
</Text>
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
|
||||
Remember my choice and skip this dialog next time
|
||||
{t('listing.deletion.rememberChoice')}
|
||||
</Checkbox>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Pie } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
|
||||
|
||||
import './ChartCard.less';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
|
||||
|
||||
export default function PieChartCard({ data = [] }) {
|
||||
const t = useTranslation();
|
||||
const { labels, values } = React.useMemo(() => {
|
||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||
const lbls = Array.isArray(data.labels) ? data.labels : [];
|
||||
@@ -92,6 +94,12 @@ export default function PieChartCard({ data = [] }) {
|
||||
const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
|
||||
|
||||
return (
|
||||
<>{isEmpty ? <div className="chartCard__no__data">No Data</div> : <Pie data={chartData} options={options} />}</>
|
||||
<>
|
||||
{isEmpty ? (
|
||||
<div className="chartCard__no__data">{t('dashboard.noData')}</div>
|
||||
) : (
|
||||
<Pie data={chartData} options={options} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,16 +6,18 @@
|
||||
import './FredyFooter.less';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
import { Layout } from '@douyinfe/semi-ui-19';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function FredyFooter() {
|
||||
const t = useTranslation();
|
||||
const { Footer } = Layout;
|
||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
return (
|
||||
<Footer className="fredyFooter">
|
||||
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
|
||||
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || t('common.na')}</span>
|
||||
<span className="fredyFooter__credit">
|
||||
Made with ❤️ by{' '}
|
||||
{t('footer.madeWith')}{' '}
|
||||
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
|
||||
Christian Kellner
|
||||
</a>
|
||||
|
||||
@@ -48,12 +48,14 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
|
||||
import JobsTable from '../../table/JobsTable.jsx';
|
||||
|
||||
import './JobGrid.less';
|
||||
import { useTranslation } from '../../../services/i18n/i18n.jsx';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||
|
||||
const JobGrid = () => {
|
||||
const t = useTranslation();
|
||||
const jobsData = useSelector((state) => state.jobsData);
|
||||
const actions = useActions();
|
||||
const navigate = useNavigate();
|
||||
@@ -104,7 +106,7 @@ const JobGrid = () => {
|
||||
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
||||
// notify finish if it was triggered by this view
|
||||
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||
Toast.success('Job finished');
|
||||
Toast.success(t('jobs.toastFinished'));
|
||||
pendingJobIdRef.current = null;
|
||||
}
|
||||
}
|
||||
@@ -161,17 +163,17 @@ const JobGrid = () => {
|
||||
}
|
||||
if (type === 'job') {
|
||||
await xhrDelete('/api/jobs', { jobId });
|
||||
Toast.success('Job and listings successfully removed');
|
||||
Toast.success(t('jobs.toastDeletedWithListings'));
|
||||
} else if (type === 'listings') {
|
||||
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||
Toast.success('Listings successfully removed');
|
||||
Toast.success(t('jobs.toastListingsDeleted'));
|
||||
}
|
||||
loadData();
|
||||
if (type === 'job') {
|
||||
actions.jobsData.getJobs(); // refresh select list too
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error performing deletion');
|
||||
Toast.error(error.message || t('jobs.toastDeleteError'));
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setPendingDeletion(null);
|
||||
@@ -181,7 +183,7 @@ const JobGrid = () => {
|
||||
const onJobStatusChanged = async (jobId, status) => {
|
||||
try {
|
||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||
Toast.success('Job status successfully changed');
|
||||
Toast.success(t('jobs.toastStatusChanged'));
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error.error);
|
||||
@@ -192,21 +194,21 @@ const JobGrid = () => {
|
||||
try {
|
||||
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||
if (response.status === 202) {
|
||||
Toast.success('Job run started');
|
||||
Toast.success(t('jobs.toastRunStarted'));
|
||||
} else {
|
||||
Toast.info('Job run requested');
|
||||
Toast.info(t('jobs.toastRunRequested'));
|
||||
}
|
||||
pendingJobIdRef.current = jobId;
|
||||
loadData();
|
||||
} catch (error) {
|
||||
if (error?.status === 409) {
|
||||
Toast.warning(error?.json?.message || 'Job is already running');
|
||||
Toast.warning(error?.json?.message || t('jobs.toastAlreadyRunning'));
|
||||
} else if (error?.status === 403) {
|
||||
Toast.error('You are not allowed to run this job');
|
||||
Toast.error(t('jobs.toastNotAllowed'));
|
||||
} else if (error?.status === 404) {
|
||||
Toast.error('Job not found');
|
||||
Toast.error(t('jobs.toastNotFound'));
|
||||
} else {
|
||||
Toast.error('Failed to trigger job');
|
||||
Toast.error(t('jobs.toastRunFailed'));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -222,7 +224,7 @@ const JobGrid = () => {
|
||||
className="jobGrid__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder="Search"
|
||||
placeholder={t('jobs.searchPlaceholder')}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
|
||||
@@ -235,39 +237,44 @@ const JobGrid = () => {
|
||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Active</Radio>
|
||||
<Radio value="false">Inactive</Radio>
|
||||
<Radio value="all">{t('jobs.filterAll')}</Radio>
|
||||
<Radio value="true">{t('jobs.filterActive')}</Radio>
|
||||
<Radio value="false">{t('jobs.filterInactive')}</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
|
||||
<Select.Option value="name">Name</Select.Option>
|
||||
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
||||
<Select.Option value="enabled">Status</Select.Option>
|
||||
<Select
|
||||
prefix={t('jobs.sortPrefix')}
|
||||
style={{ width: 200 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="name">{t('jobs.sortByName')}</Select.Option>
|
||||
<Select.Option value="numberOfFoundListings">{t('jobs.sortByListings')}</Select.Option>
|
||||
<Select.Option value="enabled">{t('jobs.sortByStatus')}</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
title={sortDir === 'asc' ? t('jobs.sortAscending') : t('jobs.sortDescending')}
|
||||
/>
|
||||
|
||||
<div className="jobGrid__topbar__view-toggle">
|
||||
<Tooltip content="Grid view">
|
||||
<Tooltip content={t('jobs.tooltipGridView')}>
|
||||
<Button
|
||||
icon={<IconGridView />}
|
||||
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||
onClick={() => actions.userSettings.setJobsViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
aria-label={t('common.ariaGridView')}
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Table view">
|
||||
<Tooltip content={t('jobs.tooltipTableView')}>
|
||||
<Button
|
||||
icon={<IconList />}
|
||||
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||
onClick={() => actions.userSettings.setJobsViewMode('table')}
|
||||
aria-label="Table view"
|
||||
aria-label={t('common.ariaTableView')}
|
||||
aria-pressed={viewMode === 'table'}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -278,7 +285,7 @@ const JobGrid = () => {
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No jobs available yet..."
|
||||
description={t('jobs.empty')}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -296,7 +303,7 @@ const JobGrid = () => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{job.isOnlyShared && (
|
||||
<Popover content={getPopoverContent('This job has been shared with you - read only.')}>
|
||||
<Popover content={getPopoverContent(t('jobs.cardSharedReadOnly'))}>
|
||||
<div>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</div>
|
||||
@@ -304,7 +311,7 @@ const JobGrid = () => {
|
||||
)}
|
||||
{job.running && (
|
||||
<Tag color="green" variant="light" size="small">
|
||||
RUNNING
|
||||
{t('jobs.cardRunning')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
@@ -314,19 +321,19 @@ const JobGrid = () => {
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
||||
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconHome size="small" /> Listings
|
||||
<IconHome size="small" /> {t('jobs.cardListings')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
|
||||
<span className="jobGrid__card__stat__number">{job.provider?.length || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconBriefcase size="small" /> Providers
|
||||
<IconBriefcase size="small" /> {t('jobs.cardProviders')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
|
||||
<span className="jobGrid__card__stat__number">{job.notificationAdapter?.length || 0}</span>
|
||||
<span className="jobGrid__card__stat__label">
|
||||
<IconBell size="small" /> Adapters
|
||||
<IconBell size="small" /> {t('jobs.cardAdapters')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,11 +349,11 @@ const JobGrid = () => {
|
||||
size="small"
|
||||
/>
|
||||
<Text type="secondary" size="small">
|
||||
Active
|
||||
{t('jobs.cardActive')}
|
||||
</Text>
|
||||
</div>
|
||||
<div className="jobGrid__actions">
|
||||
<Popover content={getPopoverContent('Run Job')}>
|
||||
<Popover content={getPopoverContent(t('jobs.popoverRunJob'))}>
|
||||
<div>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -359,7 +366,7 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Edit a Job')}>
|
||||
<Popover content={getPopoverContent(t('jobs.popoverEditJob'))}>
|
||||
<div>
|
||||
<Button
|
||||
type="secondary"
|
||||
@@ -370,7 +377,7 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Clone Job')}>
|
||||
<Popover content={getPopoverContent(t('jobs.popoverCloneJob'))}>
|
||||
<div>
|
||||
<Button
|
||||
type="tertiary"
|
||||
@@ -381,7 +388,7 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
||||
<Popover content={getPopoverContent(t('jobs.popoverDeleteListings'))}>
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
@@ -392,7 +399,7 @@ const JobGrid = () => {
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover content={getPopoverContent('Delete Job')}>
|
||||
<Popover content={getPopoverContent(t('jobs.popoverDeleteJob'))}>
|
||||
<div>
|
||||
<Button
|
||||
type="danger"
|
||||
@@ -433,14 +440,10 @@ const JobGrid = () => {
|
||||
)}
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||
title={pendingDeletion?.type === 'job' ? t('jobs.deletion.title') : t('listing.deletion.title')}
|
||||
showOptions={pendingDeletion?.type !== 'job'}
|
||||
defaultDeleteType={defaultDeleteType}
|
||||
message={
|
||||
pendingDeletion?.type === 'job'
|
||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||
: 'How would you like to delete the selected listing(s)?'
|
||||
}
|
||||
message={pendingDeletion?.type === 'job' ? t('jobs.deletion.message') : t('listing.deletion.message')}
|
||||
onConfirm={confirmDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
|
||||
@@ -19,119 +19,130 @@ import * as timeService from '../../../services/time/timeService.js';
|
||||
import StatusControl from '../../listings/StatusControl.jsx';
|
||||
|
||||
import './ListingsGrid.less';
|
||||
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
||||
*/
|
||||
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
|
||||
<div className="listingsGrid__grid">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="listingsGrid__card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="listingsGrid__card__image-wrapper">
|
||||
<img
|
||||
src={item.image_url || no_image}
|
||||
alt={item.title}
|
||||
onError={(e) => {
|
||||
e.target.src = no_image;
|
||||
}}
|
||||
/>
|
||||
{!item.is_active && (
|
||||
<div className="listingsGrid__card__inactive-watermark">
|
||||
<span>Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
||||
<button
|
||||
type="button"
|
||||
className="listingsGrid__card__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
|
||||
const t = useTranslation();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className="listingsGrid__grid">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="listingsGrid__card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="listingsGrid__card__image-wrapper">
|
||||
<img
|
||||
src={item.image_url || no_image}
|
||||
alt={item.title}
|
||||
onError={(e) => {
|
||||
e.target.src = no_image;
|
||||
}}
|
||||
/>
|
||||
{!item.is_active && (
|
||||
<div className="listingsGrid__card__inactive-watermark">
|
||||
<span>{t('listings.cardInactive')}</span>
|
||||
</div>
|
||||
)}
|
||||
<Tooltip
|
||||
content={
|
||||
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||
}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="listingsGrid__card__body">
|
||||
<div className="listingsGrid__card__title" title={item.title}>
|
||||
{item.title}
|
||||
<button
|
||||
type="button"
|
||||
className="listingsGrid__card__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={
|
||||
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||
}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{item.price && (
|
||||
<div className="listingsGrid__card__price">
|
||||
<IconCart size="small" />
|
||||
{item.price}
|
||||
|
||||
<div className="listingsGrid__card__body">
|
||||
<div className="listingsGrid__card__title" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
)}
|
||||
{item.address && (
|
||||
{item.price && (
|
||||
<div className="listingsGrid__card__price">
|
||||
<IconCart size="small" />
|
||||
{item.price}
|
||||
</div>
|
||||
)}
|
||||
{item.address && (
|
||||
<div className="listingsGrid__card__meta">
|
||||
<IconMapPin />
|
||||
{item.address}
|
||||
</div>
|
||||
)}
|
||||
<div className="listingsGrid__card__meta">
|
||||
<IconMapPin />
|
||||
{item.address}
|
||||
<IconBriefcase />
|
||||
{item.provider}
|
||||
</div>
|
||||
)}
|
||||
<div className="listingsGrid__card__meta">
|
||||
<IconBriefcase />
|
||||
{item.provider}
|
||||
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false, locale)}</div>
|
||||
</div>
|
||||
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
|
||||
</div>
|
||||
|
||||
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
||||
<StatusControl
|
||||
status={item.status?.status ?? null}
|
||||
compact
|
||||
onChange={(next) => onStatusChange?.(item, next)}
|
||||
onTriggerClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip content="Original Listing">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconLink />}
|
||||
style={{ color: '#60a5fa' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(item.link, '_blank');
|
||||
}}
|
||||
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
||||
<StatusControl
|
||||
status={item.status?.status ?? null}
|
||||
compact
|
||||
onChange={(next) => onStatusChange?.(item, next)}
|
||||
onTriggerClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="View in Fredy">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconEyeOpened />}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipOriginalListing')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconLink />}
|
||||
style={{ color: '#60a5fa' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(item.link, '_blank');
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipViewInFredy')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconEyeOpened />}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingsGrid;
|
||||
|
||||
@@ -22,8 +22,10 @@ import ListingsTable from '../table/ListingsTable.jsx';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
import './ListingsOverview.less';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
const ListingsOverview = ({ mode = 'all' }) => {
|
||||
const t = useTranslation();
|
||||
const isWatchlistMode = mode === 'watchlist';
|
||||
const listingsData = useSelector((state) => state.listingsData);
|
||||
const providers = useSelector((state) => state.provider);
|
||||
@@ -106,22 +108,24 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await xhrPost('/api/listings/watch', { listingId: item.id });
|
||||
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
|
||||
Toast.success(
|
||||
item.isWatched === 1 ? t('listings.toastRemovedFromWatchlist') : t('listings.toastAddedToWatchlist'),
|
||||
);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to operate Watchlist');
|
||||
Toast.error(t('listings.toastWatchlistError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (item, nextStatus) => {
|
||||
try {
|
||||
await actions.listingsData.setListingStatus(item.id, nextStatus);
|
||||
Toast.success(nextStatus ? `Marked as ${nextStatus}` : 'Status cleared');
|
||||
Toast.success(nextStatus ? `Marked as ${nextStatus}` : t('listings.toastStatusCleared'));
|
||||
loadData();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
Toast.error('Failed to update status');
|
||||
Toast.error(t('listings.toastStatusUpdateError'));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -142,10 +146,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||
}
|
||||
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||
Toast.success('Listing successfully removed');
|
||||
Toast.success(t('listings.toastDeleted'));
|
||||
loadData();
|
||||
} catch (error) {
|
||||
Toast.error(error.message || 'Error deleting listing');
|
||||
Toast.error(error.message || t('listings.toastDeleteError'));
|
||||
} finally {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
@@ -161,7 +165,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
className="listingsOverview__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
showClear
|
||||
placeholder="Search"
|
||||
placeholder={t('listings.searchPlaceholder')}
|
||||
defaultValue={freeTextFilter ?? ''}
|
||||
onChange={handleFilterChange}
|
||||
/>
|
||||
@@ -176,9 +180,9 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Active</Radio>
|
||||
<Radio value="false">Inactive</Radio>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterActive')}</Radio>
|
||||
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
||||
</RadioGroup>
|
||||
|
||||
{!isWatchlistMode && (
|
||||
@@ -192,14 +196,14 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Radio value="all">All</Radio>
|
||||
<Radio value="true">Watched</Radio>
|
||||
<Radio value="false">Unwatched</Radio>
|
||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
||||
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
||||
</RadioGroup>
|
||||
)}
|
||||
|
||||
<Select
|
||||
placeholder="Status"
|
||||
placeholder={t('listings.filterStatusPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setStatusFilter(val ?? null);
|
||||
@@ -208,14 +212,14 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
value={statusFilter}
|
||||
style={{ width: 150 }}
|
||||
>
|
||||
<Select.Option value="applied">Applied</Select.Option>
|
||||
<Select.Option value="rejected">Rejected</Select.Option>
|
||||
<Select.Option value="accepted">Accepted</Select.Option>
|
||||
<Select.Option value="none">No status</Select.Option>
|
||||
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
||||
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
||||
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
||||
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Provider"
|
||||
placeholder={t('listings.filterProviderPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setProviderFilter(val);
|
||||
@@ -232,7 +236,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
placeholder="Job"
|
||||
placeholder={t('listings.filterJobPlaceholder')}
|
||||
showClear
|
||||
onChange={(val) => {
|
||||
setJobNameFilter(val);
|
||||
@@ -249,40 +253,40 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
prefix="Sort by"
|
||||
prefix={t('listings.sortPrefix')}
|
||||
className="listingsOverview__topbar__sort"
|
||||
style={{ width: 220 }}
|
||||
value={sortField}
|
||||
onChange={(val) => setSortField(val)}
|
||||
>
|
||||
<Select.Option value="job_name">Job Name</Select.Option>
|
||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
||||
<Select.Option value="price">Price</Select.Option>
|
||||
<Select.Option value="provider">Provider</Select.Option>
|
||||
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
||||
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
||||
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
||||
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
||||
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||
/>
|
||||
|
||||
<div className="listingsOverview__topbar__view-toggle">
|
||||
<Tooltip content="Grid view">
|
||||
<Tooltip content={t('listings.tooltipGridView')}>
|
||||
<Button
|
||||
icon={<IconGridView />}
|
||||
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
||||
aria-label="Grid view"
|
||||
aria-label={t('common.ariaGridView')}
|
||||
aria-pressed={viewMode === 'grid'}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Table view">
|
||||
<Tooltip content={t('listings.tooltipTableView')}>
|
||||
<Button
|
||||
icon={<IconList />}
|
||||
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
||||
aria-label="Table view"
|
||||
aria-label={t('common.ariaTableView')}
|
||||
aria-pressed={viewMode === 'table'}
|
||||
/>
|
||||
</Tooltip>
|
||||
@@ -293,7 +297,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description="No listings available yet..."
|
||||
description={t('listings.empty')}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -8,27 +8,12 @@ import { Dropdown, Button, Tooltip } from '@douyinfe/semi-ui-19';
|
||||
import { IconChevronDown } from '@douyinfe/semi-icons';
|
||||
|
||||
import './StatusControl.less';
|
||||
|
||||
const STATUS_TOOLTIP =
|
||||
'Track where you stand with this listing: Applied once you have reached out, Rejected if it did not work out, or Accepted if you got it.';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
|
||||
*/
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: null, label: 'None' },
|
||||
{ value: 'applied', label: 'Applied' },
|
||||
{ value: 'rejected', label: 'Rejected' },
|
||||
{ value: 'accepted', label: 'Accepted' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Look up the option metadata for a status value.
|
||||
* @param {ListingStatus} status
|
||||
*/
|
||||
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
|
||||
|
||||
/**
|
||||
* Shared control for setting a listing's user-decision status
|
||||
* (Applied / Rejected / Accepted).
|
||||
@@ -44,8 +29,21 @@ const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ??
|
||||
* @param {(e: React.MouseEvent) => void} [props.onTriggerClick] - Optional click handler to stop propagation on the trigger.
|
||||
*/
|
||||
export default function StatusControl({ status = null, onChange, compact = false, onTriggerClick }) {
|
||||
const t = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: null, label: t('listings.status.none') },
|
||||
{ value: 'applied', label: t('listings.status.applied') },
|
||||
{ value: 'rejected', label: t('listings.status.rejected') },
|
||||
{ value: 'accepted', label: t('listings.status.accepted') },
|
||||
];
|
||||
|
||||
const STATUS_TOOLTIP = t('listings.status.tooltip');
|
||||
|
||||
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
|
||||
|
||||
const current = optionFor(status);
|
||||
|
||||
const handlePick = (next) => {
|
||||
@@ -94,7 +92,7 @@ export default function StatusControl({ status = null, onChange, compact = false
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{status ? current.label : 'Status'}
|
||||
{status ? current.label : t('listings.status.statusLabel')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -13,8 +13,10 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import './Navigate.less';
|
||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function Navigation({ isAdmin }) {
|
||||
const t = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -28,16 +30,16 @@ export default function Navigation({ isAdmin }) {
|
||||
}, [width]);
|
||||
|
||||
const items = [
|
||||
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
|
||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
||||
{ itemKey: '/dashboard', text: t('nav.dashboard'), icon: <IconHistogram /> },
|
||||
{ itemKey: '/jobs', text: t('nav.jobs'), icon: <IconTerminal /> },
|
||||
{
|
||||
itemKey: 'listings',
|
||||
text: 'Listings',
|
||||
text: t('nav.listings'),
|
||||
icon: <IconStar />,
|
||||
items: [
|
||||
{ itemKey: '/listings', text: 'Overview' },
|
||||
{ itemKey: '/map', text: 'Map View' },
|
||||
{ itemKey: '/listings/watchlist', text: 'Watchlist' },
|
||||
{ itemKey: '/listings', text: t('nav.listingsOverview') },
|
||||
{ itemKey: '/map', text: t('nav.mapView') },
|
||||
{ itemKey: '/listings/watchlist', text: t('nav.watchlist') },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -45,19 +47,19 @@ export default function Navigation({ isAdmin }) {
|
||||
if (isAdmin) {
|
||||
items.push({
|
||||
itemKey: 'settings',
|
||||
text: 'Settings',
|
||||
text: t('nav.settings'),
|
||||
icon: <IconSetting />,
|
||||
items: [
|
||||
{ itemKey: '/users', text: 'User Management' },
|
||||
{ itemKey: '/generalSettings', text: 'Settings' },
|
||||
{ itemKey: '/users', text: t('nav.userManagement') },
|
||||
{ itemKey: '/generalSettings', text: t('nav.settingsPage') },
|
||||
],
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
itemKey: 'settings',
|
||||
text: 'Settings',
|
||||
text: t('nav.settings'),
|
||||
icon: <IconSetting />,
|
||||
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
|
||||
items: [{ itemKey: '/generalSettings', text: t('nav.settingsPage') }],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -104,7 +106,7 @@ export default function Navigation({ isAdmin }) {
|
||||
<button
|
||||
className="navigate__toggle-btn"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
title={collapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
|
||||
>
|
||||
<IconSidebar size="default" />
|
||||
</button>
|
||||
|
||||
@@ -10,10 +10,12 @@ import newsConfig from '../../assets/news/news.json';
|
||||
import { useActions, useSelector } from '../../services/state/store';
|
||||
|
||||
import './NewsModal.less';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||
|
||||
const NewsModal = () => {
|
||||
const t = useTranslation();
|
||||
const screenWidth = useScreenWidth();
|
||||
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||
@@ -38,7 +40,7 @@ const NewsModal = () => {
|
||||
(item.media.includes('mp4') ? (
|
||||
<video controls width="500">
|
||||
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
|
||||
Your browser does not support the video tag.
|
||||
{t('news.videoFallback')}
|
||||
</video>
|
||||
) : (
|
||||
<img
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
*/
|
||||
|
||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function InsufficientPermission() {
|
||||
const t = useTranslation();
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||
<img src={insufficientPermission} height={250} />
|
||||
<br />
|
||||
<h4>Insufficient permission :(</h4>
|
||||
<h4>{t('permission.title')}</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,112 +17,116 @@ import {
|
||||
} from '@douyinfe/semi-icons';
|
||||
|
||||
import './JobsTable.less';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @param {{ jobs: object[], onRun: Function, onEdit: Function, onClone: Function, onDeleteListings: Function, onDeleteJob: Function, onStatusChange: Function }} props
|
||||
*/
|
||||
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => (
|
||||
<div className="jobsTable">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
|
||||
<div className="jobsTable__row__dot">
|
||||
<span
|
||||
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => {
|
||||
const t = useTranslation();
|
||||
return (
|
||||
<div className="jobsTable">
|
||||
{jobs.map((job) => (
|
||||
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
|
||||
<div className="jobsTable__row__dot">
|
||||
<span
|
||||
className={`jobsTable__row__dot__indicator${job.enabled ? ' jobsTable__row__dot__indicator--active' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__name" title={job.name}>
|
||||
{job.name}
|
||||
</div>
|
||||
<div className="jobsTable__row__name" title={job.name}>
|
||||
{job.name}
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
|
||||
<IconHome size="small" />
|
||||
{job.numberOfFoundListings || 0}
|
||||
</div>
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--blue">
|
||||
<IconHome size="small" />
|
||||
{job.numberOfFoundListings || 0}
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
|
||||
<IconBriefcase size="small" />
|
||||
{job.provider?.length || 0}
|
||||
</div>
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--orange">
|
||||
<IconBriefcase size="small" />
|
||||
{job.provider?.length || 0}
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
|
||||
<IconBell size="small" />
|
||||
{job.notificationAdapter?.length || 0}
|
||||
</div>
|
||||
<div className="jobsTable__row__stat jobsTable__row__stat--purple">
|
||||
<IconBell size="small" />
|
||||
{job.notificationAdapter?.length || 0}
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__badges">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
onChange={(checked) => onStatusChange(job.id, checked)}
|
||||
/>
|
||||
{job.running && (
|
||||
<Tag color="green" variant="light" size="small">
|
||||
RUNNING
|
||||
</Tag>
|
||||
)}
|
||||
{job.isOnlyShared && (
|
||||
<Tooltip content="Shared with you - read only">
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</span>
|
||||
<div className="jobsTable__row__badges">
|
||||
<Switch
|
||||
size="small"
|
||||
checked={job.enabled}
|
||||
disabled={job.isOnlyShared}
|
||||
onChange={(checked) => onStatusChange(job.id, checked)}
|
||||
/>
|
||||
{job.running && (
|
||||
<Tag color="green" variant="light" size="small">
|
||||
{t('jobs.cardRunning')}
|
||||
</Tag>
|
||||
)}
|
||||
{job.isOnlyShared && (
|
||||
<Tooltip content={t('jobs.tableSharedTooltip')}>
|
||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__actions">
|
||||
<Tooltip content={t('jobs.tableRunJob')}>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ background: '#21aa21b5' }}
|
||||
size="small"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
onClick={() => onRun(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip content={t('jobs.tableEditJob')}>
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onEdit(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('jobs.tableCloneJob')}>
|
||||
<Button
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<IconCopy />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onClone(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('jobs.tableDeleteListings')}>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onDeleteListings(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('jobs.tableDeleteJob')}>
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onDeleteJob(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="jobsTable__row__actions">
|
||||
<Tooltip content="Run Job">
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ background: '#21aa21b5' }}
|
||||
size="small"
|
||||
theme="solid"
|
||||
icon={<IconPlayCircle />}
|
||||
disabled={job.isOnlyShared || job.running}
|
||||
onClick={() => onRun(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Edit Job">
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
icon={<IconEdit />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onEdit(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Clone Job">
|
||||
<Button
|
||||
type="tertiary"
|
||||
size="small"
|
||||
icon={<IconCopy />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onClone(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Delete all found Listings">
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDescend2 />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onDeleteListings(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Delete Job">
|
||||
<Button
|
||||
type="danger"
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
disabled={job.isOnlyShared}
|
||||
onClick={() => onDeleteJob(job.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobsTable;
|
||||
|
||||
@@ -19,120 +19,127 @@ import * as timeService from '../../services/time/timeService.js';
|
||||
import StatusControl from '../listings/StatusControl.jsx';
|
||||
|
||||
import './ListingsTable.less';
|
||||
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
/**
|
||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
||||
*/
|
||||
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
|
||||
<div className="listingsTable">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="listingsTable__row__thumb">
|
||||
<img
|
||||
src={item.image_url || no_image}
|
||||
alt={item.title}
|
||||
onError={(e) => {
|
||||
e.target.src = no_image;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => {
|
||||
const t = useTranslation();
|
||||
const locale = useLocale();
|
||||
return (
|
||||
<div className="listingsTable">
|
||||
{listings.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onNavigate(item.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
|
||||
}}
|
||||
>
|
||||
<div className="listingsTable__row__thumb">
|
||||
<img
|
||||
src={item.image_url || no_image}
|
||||
alt={item.title}
|
||||
onError={(e) => {
|
||||
e.target.src = no_image;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="listingsTable__row__title" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="listingsTable__row__title" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
|
||||
<div className="listingsTable__row__price">
|
||||
{item.price ? (
|
||||
formatEuroPrice(item.price)
|
||||
) : (
|
||||
<span className="listingsTable__row__empty">---</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="listingsTable__row__price">
|
||||
{item.price ? formatEuroPrice(item.price) : <span className="listingsTable__row__empty">---</span>}
|
||||
</div>
|
||||
|
||||
<div className="listingsTable__row__address">
|
||||
{item.address ? (
|
||||
<>
|
||||
<IconMapPin size="small" />
|
||||
{item.address}
|
||||
</>
|
||||
) : (
|
||||
<span className="listingsTable__row__empty">---</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="listingsTable__row__address">
|
||||
{item.address ? (
|
||||
<>
|
||||
<IconMapPin size="small" />
|
||||
{item.address}
|
||||
</>
|
||||
) : (
|
||||
<span className="listingsTable__row__empty">---</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="listingsTable__row__meta">
|
||||
<IconBriefcase size="small" />
|
||||
{item.provider}
|
||||
</div>
|
||||
<div className="listingsTable__row__meta">
|
||||
<IconBriefcase size="small" />
|
||||
{item.provider}
|
||||
</div>
|
||||
|
||||
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
|
||||
<div className="listingsTable__row__date">{timeService.format(item.created_at, false, locale)}</div>
|
||||
|
||||
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
||||
<StatusControl
|
||||
status={item.status?.status ?? null}
|
||||
compact
|
||||
onChange={(next) => onStatusChange?.(item, next)}
|
||||
onTriggerClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
||||
<button
|
||||
type="button"
|
||||
className="listingsTable__row__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
||||
<StatusControl
|
||||
status={item.status?.status ?? null}
|
||||
compact
|
||||
onChange={(next) => onStatusChange?.(item, next)}
|
||||
onTriggerClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||
}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Original Listing">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconLink />}
|
||||
style={{ color: '#60a5fa' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(item.link, '_blank');
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="View in Fredy">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconEyeOpened />}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<button
|
||||
type="button"
|
||||
className="listingsTable__row__star"
|
||||
onClick={(e) => onWatch(e, item)}
|
||||
aria-label={
|
||||
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||
}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipOriginalListing')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconLink />}
|
||||
style={{ color: '#60a5fa' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(item.link, '_blank');
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipViewInFredy')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconEyeOpened />}
|
||||
style={{ color: '#34d399' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onNavigate(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t('listings.tooltipRemove')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(item.id);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListingsTable;
|
||||
|
||||
@@ -5,15 +5,17 @@
|
||||
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||
const t = useTranslation();
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No notification adapters found." />}
|
||||
empty={<Empty description={t('notification.tableEmptyState')} />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
title: t('notification.tableColumnName'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
|
||||
|
||||
@@ -6,23 +6,25 @@
|
||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
||||
const t = useTranslation();
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={<Empty description="No providers found." />}
|
||||
empty={<Empty description={t('provider.tableEmptyState')} />}
|
||||
columns={[
|
||||
{
|
||||
title: 'Name',
|
||||
title: t('provider.tableColumnName'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'URL',
|
||||
title: t('provider.tableColumnUrl'),
|
||||
dataIndex: 'url',
|
||||
render: (_, data) => {
|
||||
return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
|
||||
return <Text link={{ href: data.url, target: '_blank' }}>{t('provider.tableOpenProvider')}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,19 +7,25 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
|
||||
import { format } from '../../services/time/timeService';
|
||||
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
|
||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||
|
||||
const empty = (
|
||||
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
|
||||
);
|
||||
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||
const t = useTranslation();
|
||||
const locale = useLocale();
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={t('users.emptyState')}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Table
|
||||
pagination={false}
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: 'User',
|
||||
title: t('users.tableColumnUser'),
|
||||
dataIndex: 'username',
|
||||
render: (value, record) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||
@@ -38,23 +44,23 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
||||
padding: '0 8px',
|
||||
}}
|
||||
>
|
||||
ADMIN
|
||||
{t('users.tableAdminBadge')}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Last login',
|
||||
title: t('users.tableColumnLastLogin'),
|
||||
dataIndex: 'lastLogin',
|
||||
render: (value) => (value == null ? '---' : format(value)),
|
||||
render: (value) => (value == null ? '---' : format(value, true, locale)),
|
||||
},
|
||||
{
|
||||
title: 'Jobs',
|
||||
title: t('users.tableColumnJobs'),
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: 'MCP Token',
|
||||
title: t('users.tableColumnMcpToken'),
|
||||
dataIndex: 'mcpToken',
|
||||
render: (value) => (
|
||||
<span
|
||||
|
||||
@@ -9,6 +9,7 @@ import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
import './TrackingModal.less';
|
||||
import inDevelopment from '../../services/developmentMode.js';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
const saveResponse = async (analyticsEnabled) => {
|
||||
await xhrPost('/api/admin/generalSettings', {
|
||||
@@ -17,6 +18,8 @@ const saveResponse = async (analyticsEnabled) => {
|
||||
};
|
||||
|
||||
export default function TrackingModal() {
|
||||
const t = useTranslation();
|
||||
|
||||
if (inDevelopment()) {
|
||||
return null;
|
||||
}
|
||||
@@ -34,27 +37,17 @@ export default function TrackingModal() {
|
||||
}}
|
||||
maskClosable={false}
|
||||
closable={false}
|
||||
okText="Yes! I want to help"
|
||||
cancelText="No, thanks"
|
||||
okText={t('tracking.okText')}
|
||||
cancelText={t('tracking.cancelText')}
|
||||
>
|
||||
<Logo white />
|
||||
<div className="trackingModal__description">
|
||||
<p>Hey 👋</p>
|
||||
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
||||
<p>
|
||||
Fredy is completely free (and will always remain free). If you’d like, you can support me by donating through
|
||||
my GitHub, but there’s absolutely no obligation to do so.
|
||||
</p>
|
||||
<p>
|
||||
However, it would be a huge help if you’d allow me to collect some analytical data. Wait, before you click
|
||||
"no", let me explain. If you agree, Fredy will send a ping once every 6 hours to my internal tracking project.
|
||||
(Will be open-sourced soon)
|
||||
</p>
|
||||
<p>
|
||||
The data includes: names of active adapters/providers, OS, architecture, Node version, and language. The
|
||||
information is entirely anonymous and helps me understand which adapters/providers are most frequently used.
|
||||
</p>
|
||||
<p>Thanks🤘</p>
|
||||
<p>{t('tracking.greeting')}</p>
|
||||
<p>{t('tracking.paragraph1')}</p>
|
||||
<p>{t('tracking.paragraph2')}</p>
|
||||
<p>{t('tracking.paragraph3')}</p>
|
||||
<p>{t('tracking.paragraph4')}</p>
|
||||
<p>{t('tracking.thanks')}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -9,10 +9,12 @@ import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
|
||||
import { useSelector } from '../../services/state/store.js';
|
||||
|
||||
import './VersionBanner.less';
|
||||
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function VersionBanner() {
|
||||
const t = useTranslation();
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
@@ -28,13 +30,13 @@ export default function VersionBanner() {
|
||||
<Space spacing={8} align="center">
|
||||
<IconAlertCircle size="small" />
|
||||
<Text strong size="small">
|
||||
New version available
|
||||
{t('version.newVersionAvailable')}
|
||||
</Text>
|
||||
<Tag color="amber" size="small" shape="circle">
|
||||
{versionUpdate.version}
|
||||
</Tag>
|
||||
<Text type="tertiary" size="small">
|
||||
Current: {versionUpdate.localFredyVersion}
|
||||
{t('version.currentLabel', { version: versionUpdate.localFredyVersion })}
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
@@ -44,7 +46,7 @@ export default function VersionBanner() {
|
||||
iconPosition="right"
|
||||
onClick={() => setModalVisible(true)}
|
||||
>
|
||||
Release notes
|
||||
{t('version.releaseNotes')}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -54,7 +56,7 @@ export default function VersionBanner() {
|
||||
<Space spacing={8} align="center">
|
||||
<Text strong>Fredy {versionUpdate.version}</Text>
|
||||
<Tag color="amber" size="small">
|
||||
New
|
||||
{t('version.newBadge')}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
@@ -63,21 +65,21 @@ export default function VersionBanner() {
|
||||
width={640}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => setModalVisible(false)}>Close</Button>
|
||||
<Button onClick={() => setModalVisible(false)}>{t('version.modalClose')}</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<IconArrowRight />}
|
||||
iconPosition="right"
|
||||
onClick={() => window.open(versionUpdate.url, '_blank')}
|
||||
>
|
||||
View on GitHub
|
||||
{t('version.viewOnGithub')}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions row size="small" className="versionBanner__details">
|
||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={t('version.yourVersion')}>{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||
<Descriptions.Item itemKey={t('version.latestVersion')}>{versionUpdate.version}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div className="versionBanner__notes">
|
||||
<MarkdownRender raw={versionUpdate.body} />
|
||||
|
||||
Reference in New Issue
Block a user