Ui-Redesign (#203)

* new ui design

* improving ui design

* adding new screenshots

* upgrade dependencies
This commit is contained in:
Christian Kellner
2025-09-29 20:36:56 +02:00
committed by GitHub
parent 412e24b1e3
commit b6755497e4
32 changed files with 342 additions and 247 deletions

View File

@@ -8,18 +8,19 @@ import UserMutator from './views/user/mutation/UserMutator';
import JobInsight from './views/jobs/insights/JobInsight.jsx';
import { useActions, useSelector } from './services/state/store';
import { Routes, Route, Navigate } from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login';
import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs';
import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner } from '@douyinfe/semi-ui';
import { Banner, Divider } from '@douyinfe/semi-ui';
import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx';
import Navigation from './components/navigation/Navigation.jsx';
import { Layout } from '@douyinfe/semi-ui';
import FredyFooter from './components/footer/FredyFooter.jsx';
import ProcessingTimes from './views/jobs/ProcessingTimes.jsx';
export default function FredyApp() {
const actions = useActions();
@@ -27,6 +28,7 @@ export default function FredyApp() {
const currentUser = useSelector((state) => state.user.currentUser);
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
const settings = useSelector((state) => state.generalSettings.settings);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
useEffect(() => {
async function init() {
@@ -50,6 +52,7 @@ export default function FredyApp() {
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout;
return loading ? null : needsLogin() ? (
<Routes>
@@ -57,71 +60,80 @@ export default function FredyApp() {
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
) : (
<div className="app">
<div className="app__container">
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
<Layout className="app">
<Layout className="app">
<Sider>
<Navigation isAdmin={isAdmin()} />
</Sider>
<Content>
{versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && (
<>
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
description="You're currently viewing the demo version of Fredy. Jobs won't scrape websites, and any changes you make will be reverted at midnight."
/>
<br />
</>
)}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Divider />
<div className="app__content">
<Routes>
<Route path="/403" element={<InsufficientPermission />} />
<Route path="/jobs/new" element={<JobMutation />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/insights/:jobId" element={<JobInsight />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/listings" element={<Listings />} />
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
{/* Permission-aware routes */}
<Route
path="/users/new"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users/edit/:userId"
element={
<PermissionAwareRoute currentUser={currentUser}>
<UserMutator />
</PermissionAwareRoute>
}
/>
<Route
path="/users"
element={
<PermissionAwareRoute currentUser={currentUser}>
<Users />
</PermissionAwareRoute>
}
/>
<Route
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes>
</div>
</div>
<Route path="/" element={<Navigate to="/jobs" replace />} />
</Routes>
</div>
</Content>
</Layout>
<Footer>
<FredyFooter />
</Footer>
</Layout>
);
}

View File

@@ -1,12 +1,9 @@
.app {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
&__container {
padding: 1rem 1rem;
color: var(--semi-color-text-0);
background-color: #232429;
&__content {
margin: 1rem;
}
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js';
import { Typography } from '@douyinfe/semi-ui';
export default function FredyFooter() {
const { Text } = Typography;
const version = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<div className="fredyFooter">
<div className="fredyFooter__version">
<Text type="tertiary">Fredy V{version?.localFredyVersion || 'N/A'}</Text>
</div>
<div className="fredyFooter__copyRight">
<Text link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>Made with </Text>
</div>
</div>
);
}

View File

@@ -0,0 +1,17 @@
.fredyFooter {
background:rgb(53, 54, 60);
color: white;
display: flex;
justify-content: space-between;
align-items: center;
&__version {
padding-left: .5rem;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
}

View File

@@ -2,19 +2,22 @@ import React from 'react';
import { Button } from '@douyinfe/semi-ui';
import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout() {
const Logout = function Logout({ text }) {
return (
<Button
icon={<IconUser />}
type="danger"
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
Logout
</Button>
<div>
<Button
icon={<IconUser />}
type="danger"
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
{text && 'Logout'}
</Button>
</div>
);
};

View File

@@ -1,65 +0,0 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabPane } from '@douyinfe/semi-ui';
import { useLocation } from 'react-router-dom';
import { IconUser, IconTerminal, IconSetting, IconArchive } from '@douyinfe/semi-icons';
import './Menu.less';
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
const TopMenu = function TopMenu({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
return (
<Tabs className="menu" type="line" activeKey={parsePathName(location.pathname)} onTabClick={(key) => navigate(key)}>
<TabPane
itemKey="/jobs"
tab={
<span>
<IconTerminal />
Jobs
</span>
}
/>
<TabPane
itemKey="/listings"
tab={
<span>
<IconArchive />
Found listings
</span>
}
/>
{isAdmin && (
<TabPane
itemKey="/users"
tab={
<span>
<IconUser />
User
</span>
}
/>
)}
{isAdmin && (
<TabPane
itemKey="/generalSettings"
tab={
<span>
<IconSetting />
Settings
</span>
}
/>
)}
</Tabs>
);
};
export default TopMenu;

View File

@@ -1,3 +0,0 @@
.menu {
margin-top: 3rem;
}

View File

@@ -0,0 +1,9 @@
.navigate {
&__logout_Button {
align-items: center;
justify-content: center;
width: 100%;
display: flex;
}
}

View File

@@ -0,0 +1,50 @@
import React from 'react';
import { Nav } from '@douyinfe/semi-ui';
import { IconUser, IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png';
import Logout from '../logout/Logout.jsx';
import { useLocation, useNavigate } from 'react-router-dom';
import './Navigate.less';
import { useScreenWidth } from '../../hooks/screenWidth.js';
export default function Navigation({ isAdmin }) {
const navigate = useNavigate();
const location = useLocation();
const width = useScreenWidth();
const collapsed = width <= 850;
const items = [
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
{ itemKey: '/listings', text: 'Found Listings', icon: <IconStar /> },
];
if (isAdmin) {
items.push({ itemKey: '/users', text: 'User Management', icon: <IconUser /> });
items.push({ itemKey: '/generalSettings', text: 'Settings', icon: <IconSetting /> });
}
function parsePathName(name) {
const split = name.split('/').filter((s) => s.length !== 0);
return '/' + split[0];
}
return (
<Nav
style={{ height: '100%', width: collapsed ? '' : '13rem' }}
items={items}
isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => {
navigate(key.itemKey);
}}
header={<img src={logoWhite} width="180" alt="Fredy Logo" />}
footer={
<div className="navigate__logout_Button">
<Logout text={!collapsed} />
</div>
}
/>
);
}

View File

@@ -8,6 +8,7 @@ export const SegmentPart = ({ name, Icon = null, children, helpText }) => {
return (
<Card
className="segmentParts"
title={
<Meta title={name} description={helpText} avatar={Icon == null ? null : <Icon size="extra-extra-small" />} />
}

View File

@@ -1,4 +1,7 @@
.segmentParts {
border: 1px solid #323232 !important;
border-radius: 5px !important;
color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60);
margin: 2rem;
}

View File

@@ -10,7 +10,7 @@ const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description={'No jobs available.'}
description="No jobs available. Why don't you create one? ;)"
/>
);

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react';
import { Table, Popover, Input, Descriptions, Tag, Image } from '@douyinfe/semi-ui';
import { Table, Popover, Input, Descriptions, Tag, Image, Empty } from '@douyinfe/semi-ui';
import { useActions, useSelector } from '../../services/state/store.js';
import { IconClose, IconSearch, IconTick } from '@douyinfe/semi-icons';
import * as timeService from '../../services/time/timeService.js';
@@ -8,6 +8,7 @@ import no_image from '../../assets/no_image.jpg';
import './ListingsTable.less';
import { format } from '../../services/time/timeService.js';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
const columns = [
{
@@ -65,7 +66,7 @@ const columns = [
},
{
title: 'Price',
width: 100,
width: 110,
dataIndex: 'price',
sorter: true,
render: (text) => text + ' €',
@@ -90,11 +91,19 @@ const columns = [
},
];
const empty = (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available."
/>
);
export default function ListingsTable() {
const tableData = useSelector((state) => state.listingsTable);
const actions = useActions();
const [page, setPage] = useState(1);
const pageSize = 15;
const pageSize = 10;
const [sortData, setSortData] = useState({});
const [filter, setFilter] = useState(null);
@@ -158,6 +167,7 @@ export default function ListingsTable() {
/>
<Table
rowKey="id"
empty={empty}
hideExpandedColumn={false}
sticky={{ top: 5 }}
columns={columns}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { Banner, Descriptions } from '@douyinfe/semi-ui';
import { Collapse, Descriptions } from '@douyinfe/semi-ui';
import { useSelector } from '../../services/state/store.js';
import { MarkdownRender } from '@douyinfe/semi-ui';
@@ -8,12 +8,9 @@ import './VersionBanner.less';
export default function VersionBanner() {
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
return (
<Banner
className="versionBanner"
type="success"
icon={null}
description={
<div style={{ overflow: 'auto' }}>
<Collapse>
<Collapse.Panel header="A new version of Fredy is available" itemKey="1" className="versionBanner">
<div className="versionBanner__content">
<p>A new version of Fredy is available. Update now to take advantage of the latest features and bug fixes.</p>
<Descriptions row size="small">
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
@@ -29,9 +26,9 @@ export default function VersionBanner() {
<small>Release Notes</small>
</b>
</p>
<MarkdownRender raw={versionUpdate.body} style={{ height: '200px' }} />
<MarkdownRender raw={versionUpdate.body} />
</div>
}
/>
</Collapse.Panel>
</Collapse>
);
}

View File

@@ -1,3 +1,7 @@
.versionBanner {
margin-bottom: 1rem;
background: rgba(var(--semi-teal-1), 1);
&__content {
overflow: auto;
}
}

View File

@@ -0,0 +1,23 @@
import { useState, useEffect } from 'react';
export function useScreenWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
let timeoutId;
const handleResize = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => setWidth(window.innerWidth), 100);
};
window.addEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
window.removeEventListener('resize', handleResize);
};
}, []);
return width;
}

View File

@@ -4,7 +4,6 @@ import { useActions, useSelector } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input } from '@douyinfe/semi-ui';
import { InputNumber } from '@douyinfe/semi-ui';
import Headline from '../../components/headline/Headline';
import { xhrPost } from '../../services/xhr';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui';
@@ -125,7 +124,6 @@ const GeneralSettings = function GeneralSettings() {
<div>
{!loading && (
<React.Fragment>
<Headline text="General Settings" />
<div>
<SegmentPart
name="Interval"

View File

@@ -4,14 +4,12 @@ import JobTable from '../../components/table/JobTable';
import { useSelector, useActions } from '../../services/state/store';
import { xhrDelete, xhrPut } from '../../services/xhr';
import { useNavigate } from 'react-router-dom';
import ProcessingTimes from './ProcessingTimes';
import { Button, Toast } from '@douyinfe/semi-ui';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import './Jobs.less';
export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs);
const processingTimes = useSelector((state) => state.jobs.processingTimes);
const navigate = useNavigate();
const actions = useActions();
@@ -38,7 +36,6 @@ export default function Jobs() {
return (
<div>
<div>
{processingTimes != null && <ProcessingTimes processingTimes={processingTimes} />}
<Button
type="primary"
icon={<IconPlusCircle />}

View File

@@ -1,7 +1,8 @@
.jobs {
&__newButton {
margin-top: 1rem !important;
float: right;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
}

View File

@@ -1,47 +1,56 @@
import React from 'react';
import { format } from '../../services/time/timeService';
import { Button, Descriptions, Toast } from '@douyinfe/semi-ui';
import { Button, Card, Col, Row, Toast } from '@douyinfe/semi-ui';
import { IconPlayCircle } from '@douyinfe/semi-icons';
import { xhrPost } from '../../services/xhr.js';
import './ProsessingTimes.less';
function InfoCard({ title, value }) {
return (
<Card style={{ maxWidth: '13rem', margin: '1rem', background: 'rgb(53, 54, 60)' }} title={title}>
{value}
</Card>
);
}
export default function ProcessingTimes({ processingTimes = {} }) {
if (Object.keys(processingTimes).length === 0) {
return null;
}
return (
<>
<Descriptions
row
size="small"
style={{
backgroundColor: '#35363c',
borderRadius: '4px',
padding: '10px',
}}
>
<Descriptions.Item itemKey="Processing Interval">{processingTimes.interval} min</Descriptions.Item>
{processingTimes.lastRun && (
<>
<Descriptions.Item itemKey="Last run">{format(processingTimes.lastRun)}</Descriptions.Item>
<Descriptions.Item itemKey="Next run">
{format(processingTimes.lastRun + processingTimes.interval * 60000)}
</Descriptions.Item>
<Descriptions.Item itemKey="Find Listings now">
<Button
size="small"
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
}}
>
Search now
</Button>
</Descriptions.Item>
</>
)}
</Descriptions>
</>
<Row>
<Col span={6}>
<InfoCard title="Processing Interval" value={`${processingTimes.interval} min`} />
</Col>
{processingTimes.lastRun && (
<>
<Col span={6}>
<InfoCard title="Last run" value={format(processingTimes.lastRun)} />
</Col>
<Col span={6}>
<InfoCard title="Next run" value={format(processingTimes.lastRun + processingTimes.interval * 60000)} />
</Col>
</>
)}
<Col span={6}>
<InfoCard
title="Find Listings Now"
value={
<Button
size="small"
icon={<IconPlayCircle />}
aria-label="Start now"
onClick={async () => {
await xhrPost('/api/jobs/startAll', null);
Toast.success('Successfully triggered Fredy search.');
}}
>
Search now
</Button>
}
/>
</Col>
</Row>
);
}

View File

@@ -0,0 +1,5 @@
.processingTimes {
display: flex;
gap: 1rem;
justify-content: space-between;
}

View File

@@ -52,7 +52,7 @@ const Users = function Users() {
icon={<IconPlus />}
onClick={() => navigate('/users/new')}
>
Create new User
New User
</Button>
<UserTable

View File

@@ -1,7 +1,8 @@
.users {
&__newButton {
margin-top: 1rem !important;
float: right;
float: left;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
}