fredy goes multilingual 🇩🇪 🇺🇸

This commit is contained in:
orangecoding
2026-06-04 10:35:42 +02:00
parent 019b9ac87b
commit 1dcb852ea1
40 changed files with 2072 additions and 879 deletions

View File

@@ -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>
</>
)}

View File

@@ -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} />
)}
</>
);
}

View File

@@ -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>

View File

@@ -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);

View File

@@ -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;

View File

@@ -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')}
/>
)}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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',
},

View File

@@ -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>;
},
},
{

View File

@@ -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

View File

@@ -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 ones important, and I promise it will only appear once ;)</p>
<p>
Fredy is completely free (and will always remain free). If youd like, you can support me by donating through
my GitHub, but theres absolutely no obligation to do so.
</p>
<p>
However, it would be a huge help if youd 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>
);

View File

@@ -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} />