Compare commits

...

1 Commits

Author SHA1 Message Date
Christian Kellner
f30ec4645c feat: Fredy UI redesign
* New design :)
2026-04-22 21:11:18 +02:00
43 changed files with 4004 additions and 794 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,9 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>Fredy || Real Estate Finder</title> <title>Fredy || Real Estate Finder</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head> </head>
<body theme-mode="dark"> <body theme-mode="dark">
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div> <div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "20.4.0", "version": "21.0.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",

229
tools/devMock.js Normal file
View File

@@ -0,0 +1,229 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
/**
* Lightweight dev mock server on port 9998.
* Vite proxies /api to this. Run with: node tools/devMock.js
*/
import http from 'node:http';
const now = Date.now();
const users = [{ id: 1, username: 'admin', isAdmin: true, lastLogin: now, numberOfJobs: 2, mcpToken: 'tok_abc123' }];
const jobs = [
{
id: 'job1',
name: 'Munich Apartments',
enabled: true,
running: false,
blacklist: [],
provider: [
{
id: 'immoscout',
name: 'ImmobilienScout24',
url: 'https://www.immobilienscout24.de/Suche/S-T/Wohnung-Miete/Bayern/Muenchen',
},
],
notificationAdapter: [],
specFilter: { maxPrice: 1500, minSize: 50 },
numberOfFoundListings: 2,
isOnlyShared: false,
},
{
id: 'job2',
name: 'Berlin Rentals',
enabled: true,
running: false,
blacklist: ['keller', 'EG'],
provider: [{ id: 'immo', name: 'Immowelt', url: 'https://www.immowelt.de/suche/berlin/wohnungen/mieten' }],
notificationAdapter: [],
specFilter: {},
numberOfFoundListings: 2,
isOnlyShared: false,
},
];
const listings = [
{
id: 'l1',
title: '3-Zimmer-Wohnung in Schwabing',
price: 1350,
address: 'Leopoldstr. 42, München',
provider: 'ImmobilienScout24',
createdAt: now - 3600000,
created_at: now - 3600000,
image_url: null,
link: 'https://example.com/l1',
is_active: true,
isWatched: 0,
jobId: 'job1',
job_name: 'Munich Apartments',
size: 72,
rooms: 3,
description: 'Schöne 3-Zimmer-Wohnung in bester Lage in Schwabing. Balkon, Parkett, moderne Küche.',
latitude: 48.1598,
longitude: 11.5876,
},
{
id: 'l2',
title: 'Helle 2-Zimmer near Ostbahnhof',
price: 980,
address: 'Rosenheimer Str. 15, München',
provider: 'ImmobilienScout24',
createdAt: now - 7200000,
created_at: now - 7200000,
image_url: null,
link: 'https://example.com/l2',
is_active: true,
isWatched: 1,
jobId: 'job1',
job_name: 'Munich Apartments',
size: 55,
rooms: 2,
description: 'Helle 2-Zimmer-Wohnung nahe Ostbahnhof. Ruhige Lage, gute Anbindung.',
latitude: 48.1285,
longitude: 11.6005,
},
{
id: 'l3',
title: 'Altbau in Prenzlauer Berg',
price: 1100,
address: 'Kastanienallee 28, Berlin',
provider: 'Immowelt',
createdAt: now - 86400000,
created_at: now - 86400000,
image_url: null,
link: 'https://example.com/l3',
is_active: false,
isWatched: 0,
jobId: 'job2',
job_name: 'Berlin Rentals',
size: 65,
rooms: 2,
description: 'Charmante Altbauwohnung in Prenzlauer Berg. Hohe Decken, Stuck, Holzdielen.',
latitude: 52.5397,
longitude: 13.4098,
},
{
id: 'l4',
title: '4-Zimmer Neubau Mitte',
price: 2200,
address: 'Karl-Liebknecht-Str. 5, Berlin',
provider: 'Immowelt',
createdAt: now - 172800000,
created_at: now - 172800000,
image_url: null,
link: 'https://example.com/l4',
is_active: true,
isWatched: 1,
jobId: 'job2',
job_name: 'Berlin Rentals',
size: 95,
rooms: 4,
description: 'Moderner Neubau im Herzen von Berlin Mitte. Fußbodenheizung, Aufzug, Tiefgarage.',
latitude: 52.5219,
longitude: 13.4132,
},
];
const dashboard = {
general: { interval: 30, lastRun: now - 1800000, nextRun: now + 1800000 },
kpis: { totalJobs: 2, totalListings: 4, numberOfActiveListings: 3, medianPriceOfListings: 1225 },
pie: [
{ type: 'ImmobilienScout24', value: 50 },
{ type: 'Immowelt', value: 50 },
],
};
const routes = {
'GET /api/login/user': { userId: 1, username: 'admin', isAdmin: true },
'GET /api/admin/users': users,
'GET /api/jobs/provider': [
{ id: 'immoscout', name: 'ImmobilienScout24', baseUrl: 'https://www.immobilienscout24.de' },
{ id: 'immo', name: 'Immowelt', baseUrl: 'https://www.immowelt.de' },
],
'GET /api/jobs': jobs,
'GET /api/jobs/shareableUserList': [],
'GET /api/jobs/notificationAdapter': [],
'GET /api/admin/generalSettings': { demoMode: false, analyticsEnabled: true, interval: 30 },
'GET /api/user/settings': {},
'GET /api/version': { newVersion: null },
'GET /api/tracking/trackingPois': [],
'GET /api/dashboard': dashboard,
'GET /api/demo': { demoMode: false },
'POST /api/user/settings/news-hash': {},
};
const server = http.createServer((req, res) => {
const origin = req.headers.origin || 'http://localhost:5175';
const path = req.url.split('?')[0];
const key = req.method + ' ' + path;
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
if (path === '/api/jobs/events') {
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
res.write(': connected\n\n');
const interval = setInterval(() => res.write(': ping\n\n'), 15000);
req.on('close', () => clearInterval(interval));
return;
}
res.setHeader('Content-Type', 'application/json');
const userMatch = path.match(/^\/api\/admin\/users\/(\d+)$/);
if (req.method === 'GET' && userMatch) {
const user = users.find((u) => u.id === parseInt(userMatch[1]));
res.writeHead(user ? 200 : 404);
res.end(JSON.stringify(user || { message: 'Not found' }));
return;
}
const listingMatch = path.match(/^\/api\/listings\/([^/]+)$/);
if (
req.method === 'GET' &&
listingMatch &&
!path.includes('/table') &&
!path.includes('/map') &&
!path.includes('/watch')
) {
const listing = listings.find((l) => l.id === listingMatch[1]);
res.writeHead(listing ? 200 : 404);
res.end(JSON.stringify(listing || { message: 'Not found' }));
return;
}
if (path.startsWith('/api/jobs/data')) {
res.writeHead(200);
res.end(JSON.stringify({ result: jobs, totalNumber: jobs.length, page: 1 }));
return;
}
if (path.startsWith('/api/listings/table')) {
res.writeHead(200);
res.end(JSON.stringify({ result: listings, totalNumber: listings.length, page: 1 }));
return;
}
if (path.startsWith('/api/listings/map')) {
res.writeHead(200);
res.end(JSON.stringify({ listings: listings.filter((l) => l.is_active), maxPrice: 2200 }));
return;
}
const data = routes[key];
res.writeHead(200);
res.end(JSON.stringify(data !== undefined ? data : {}));
});
server.listen(9998, () => console.warn('Dev mock ready on :9998'));

View File

@@ -1,3 +1,5 @@
@import './tokens.less';
.app { .app {
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
@@ -14,18 +16,14 @@
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
position: relative; position: relative;
padding: 24px; padding: @space-6;
background-color: var(--semi-color-bg-0); background-color: transparent;
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@media (max-width: 768px) { @media (max-width: 768px) {
padding: 12px; padding: @space-3;
} }
} }
} }
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle;
}

View File

@@ -1,16 +1,110 @@
@import './tokens.less';
body, body,
html { html {
margin: 0; margin: 0;
height: 100%; height: 100%;
width: 100%; width: 100%;
background-color: #232429; font-family: @font-ui;
background-color: @color-base;
background-image: radial-gradient(ellipse at 60% 0%, rgba(224,74,56,0.05) 0%, transparent 55%);
background-attachment: fixed;
} }
@media (max-width: 768px) {
body, html {
background-attachment: scroll;
}
}
// Semi UI theme overrides
body {
--semi-color-bg-0: @color-base !important;
--semi-color-bg-1: @color-surface !important;
--semi-color-bg-2: @color-elevated !important;
--semi-color-bg-3: @color-border !important;
--semi-color-border: @color-border !important;
--semi-color-primary: @color-accent !important;
--semi-color-primary-hover: @color-accent-dim !important;
--semi-color-primary-active: @color-accent-dim !important;
--semi-color-primary-light-default: rgba(224,74,56,0.12) !important;
--semi-color-primary-light-hover: rgba(224,74,56,0.18) !important;
--semi-color-primary-light-active: rgba(224,74,56,0.22) !important;
--semi-color-text-0: @color-text !important;
--semi-color-text-1: @color-text !important;
--semi-color-text-2: @color-muted !important;
--semi-color-text-3: @color-faint !important;
--semi-color-fill-0: rgba(255,255,255,0.04) !important;
--semi-color-fill-1: rgba(255,255,255,0.06) !important;
--semi-color-fill-2: rgba(255,255,255,0.08) !important;
--semi-font-family: @font-ui !important;
}
// Semi table row overrides
.semi-table-row-head { .semi-table-row-head {
background-color: #2b2b2b !important; background-color: rgba(255,255,255,0.06) !important;
color: #fff !important; }
.semi-table-row-head .semi-table-row-cell {
background-color: rgba(255,255,255,0.06) !important;
color: @color-muted !important;
font-size: @text-xs;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.semi-table-row-cell {
background-color: @color-surface !important;
}
.semi-table-tbody .semi-table-row:nth-child(even) .semi-table-row-cell {
background-color: @color-base !important;
}
.semi-table-tbody .semi-table-row:hover .semi-table-row-cell {
background-color: @color-elevated !important;
} }
.semi-table-row-cell { // Scrollbar
background-color: #333333 !important; ::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: @color-surface; }
::-webkit-scrollbar-thumb { background: @color-border-bright; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: @color-muted; }
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon):not(.semi-checkbox .semi-icon) {
vertical-align: middle;
}
// Suppress focus outlines — Semi uses --semi-color-primary (our red) for all rings
button:focus,
button:focus-visible,
.semi-button:focus,
.semi-button:focus-visible,
.semi-input-wrapper:focus-within,
.semi-select:focus-within,
[tabindex]:focus {
outline: none !important;
box-shadow: none !important;
}
.semi-input-wrapper-focus {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
// Semi Modal dark theme overrides
.semi-modal-content {
background: #161616 !important;
border: 1px solid @color-border !important;
border-top: 3px solid @color-accent !important;
border-radius: 14px !important;
}
.semi-modal-header {
background: transparent !important;
border-bottom: 1px solid @color-border !important;
border-radius: 14px 14px 0 0 !important;
padding: 20px 24px 16px !important;
}
.semi-modal-mask {
background: rgba(0,0,0,0.7) !important;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
} }

View File

@@ -18,6 +18,7 @@
display: grid; display: grid;
place-items: center; place-items: center;
height: 14rem; height: 14rem;
opacity: .7; color: #94a3b8;
font-size: 0.9rem;
} }
} }

View File

@@ -1,45 +1,34 @@
@import './DashboardCardColors.less'; @import './DashboardCardColors.less';
@keyframes card-glow-rotate {
0% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
25% { box-shadow: -3px 3px 14px -4px var(--card-glow); }
50% { box-shadow: -3px -3px 14px -4px var(--card-glow); }
75% { box-shadow: 3px -3px 14px -4px var(--card-glow); }
100% { box-shadow: 3px 3px 14px -4px var(--card-glow); }
}
.dashboard-card { .dashboard-card {
width: 100%; width: 100%;
height: 140px; height: 112px;
margin-bottom: 16px; border-radius: @radius-card !important;
transition: transform 0.2s, box-shadow 0.2s; border: 1px solid @color-border !important;
background-color: #181b26; background-color: @color-surface !important;
border: 1px solid #232735; transition: box-shadow @transition-card;
border-radius: 10px;
--pulse-color: rgba(255, 255, 255, 0.08);
position: relative; position: relative;
z-index: 1;
overflow: visible; overflow: visible;
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
box-shadow: 0 4px 25px -2px var(--pulse-color);
opacity: 0;
animation: pulse 5s infinite ease-in-out;
pointer-events: none;
z-index: -1;
will-change: opacity;
}
&__icon { &__icon {
font-size: 20px; font-size: 16px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--card-accent, #94a3b8); color: var(--card-accent, @color-gray-text);
} }
&__title { &__title {
color: var(--semi-color-text-2) !important; color: rgba(148, 163, 184, 0.7) !important;
font-size: 12px !important; font-size: @text-xs !important;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -49,61 +38,50 @@
} }
&__value { &__value {
font-size: 1.2rem;
font-weight: 700; font-weight: 700;
margin-bottom: 4px; margin-bottom: 4px;
color: var(--card-accent, var(--semi-color-text-0)); color: var(--card-accent, @color-text);
} }
&__desc { &__desc {
color: var(--semi-color-text-3) !important; color: @color-faint !important;
font-size: @text-xs;
} }
&.blue { &.blue {
--pulse-color: @color-blue-border;
--card-accent: @color-blue-text; --card-accent: @color-blue-text;
background-color: @color-blue-bg; --card-glow: @color-blue-border;
border-color: @color-blue-border; background-color: @color-blue-bg !important;
box-shadow: 0 2px 16px -6px @color-blue-border; border-color: @color-blue-border !important;
animation: card-glow-rotate 8s linear infinite;
} }
&.orange { &.orange {
--pulse-color: @color-orange-border;
--card-accent: @color-orange-text; --card-accent: @color-orange-text;
background-color: @color-orange-bg; --card-glow: @color-orange-border;
border-color: @color-orange-border; background-color: @color-orange-bg !important;
box-shadow: 0 2px 16px -6px @color-orange-border; border-color: @color-orange-border !important;
animation: card-glow-rotate 8s linear infinite;
} }
&.green { &.green {
--pulse-color: @color-green-border;
--card-accent: @color-green-text; --card-accent: @color-green-text;
background-color: @color-green-bg; --card-glow: @color-green-border;
border-color: @color-green-border; background-color: @color-green-bg !important;
box-shadow: 0 2px 16px -6px @color-green-border; border-color: @color-green-border !important;
animation: card-glow-rotate 8s linear infinite;
} }
&.purple { &.purple {
--pulse-color: @color-purple-border;
--card-accent: @color-purple-text; --card-accent: @color-purple-text;
background-color: @color-purple-bg; --card-glow: @color-purple-border;
border-color: @color-purple-border; background-color: @color-purple-bg !important;
box-shadow: 0 2px 16px -6px @color-purple-border; border-color: @color-purple-border !important;
animation: card-glow-rotate 8s linear infinite;
} }
&.gray { &.gray {
--pulse-color: @color-gray-border;
--card-accent: @color-gray-text; --card-accent: @color-gray-text;
background-color: @color-gray-bg; --card-glow: @color-gray-border;
border-color: @color-gray-border; background-color: @color-gray-bg !important;
box-shadow: 0 2px 16px -6px @color-gray-border; border-color: @color-gray-border !important;
} animation: card-glow-rotate 8s linear infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.1;
}
50% {
opacity: 0.4;
} }
} }

View File

@@ -1,19 +1 @@
@color-blue-bg: rgba(96, 165, 250, 0.10); @import '../../tokens.less';
@color-blue-border: #3b6ea8;
@color-blue-text: #60a5fa;
@color-orange-bg: rgba(251, 146, 60, 0.10);
@color-orange-border: #c2622a;
@color-orange-text: #fb923c;
@color-green-bg: rgba(52, 211, 153, 0.10);
@color-green-border: #2a8a61;
@color-green-text: #34d399;
@color-purple-bg: rgba(167, 139, 250, 0.10);
@color-purple-border: #6d4fc2;
@color-purple-text: #a78bfa;
@color-gray-bg: rgba(148, 163, 184, 0.10);
@color-gray-border: #323a47;
@color-gray-text: #94a3b8;

View File

@@ -6,15 +6,7 @@
import { Card, Typography, Space } from '@douyinfe/semi-ui-19'; import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less'; import './DashboardCard.less';
export default function KpiCard({ export default function KpiCard({ title, icon, value, description, color = 'gray', children }) {
title,
icon,
value,
valueFontSize = '1.5rem',
description,
color = 'gray',
children,
}) {
const { Text } = Typography; const { Text } = Typography;
return ( return (
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}> <Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
@@ -26,12 +18,12 @@ export default function KpiCard({
</Text> </Text>
</Space> </Space>
<div className="dashboard-card__content"> <div className="dashboard-card__content">
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}> <div className="dashboard-card__value">
{value} {value}
{children} {children}
</div> </div>
{description && ( {description && (
<Text size="small" type="tertiary" className="dashboard-card__desc"> <Text size="small" className="dashboard-card__desc">
{description} {description}
</Text> </Text>
)} )}

View File

@@ -5,23 +5,21 @@
import './FredyFooter.less'; import './FredyFooter.less';
import { useSelector } from '../../services/state/store.js'; import { useSelector } from '../../services/state/store.js';
import { Typography, Layout, Space, Divider } from '@douyinfe/semi-ui-19'; import { Layout } from '@douyinfe/semi-ui-19';
export default function FredyFooter() { export default function FredyFooter() {
const { Text } = Typography;
const { Footer } = Layout; const { Footer } = Layout;
const version = useSelector((state) => state.versionUpdate.versionUpdate); const version = useSelector((state) => state.versionUpdate.versionUpdate);
return ( return (
<Footer className="fredyFooter"> <Footer className="fredyFooter">
<Space split={<Divider layout="vertical" />}> <span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
<Text type="tertiary" size="small"> <span className="fredyFooter__credit">
Fredy V{version?.localFredyVersion || 'N/A'} Made with by{' '}
</Text> <a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}> Christian Kellner
Made with </a>
</Text> </span>
</Space>
</Footer> </Footer>
); );
} }

View File

@@ -1,12 +1,34 @@
@import '../../tokens.less';
.fredyFooter { .fredyFooter {
background-color: var(--semi-color-bg-1); background-color: @color-base;
border-top: 1px solid @color-border;
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
align-items: center; align-items: center;
padding: 0 1rem; padding: 10px 24px;
height: 32px; height: 36px;
border-top: 1px solid var(--semi-color-border);
z-index: 1000;
position: relative;
flex-shrink: 0; flex-shrink: 0;
box-sizing: border-box;
&__version {
font-size: @text-xs;
color: @color-faint;
font-family: @font-mono;
}
&__credit {
font-size: @text-xs;
color: @color-faint;
a {
color: @color-muted;
text-decoration: none;
transition: color @transition-fast;
&:hover {
color: @color-text;
}
}
}
} }

View File

@@ -32,7 +32,6 @@ import {
IconBriefcase, IconBriefcase,
IconBell, IconBell,
IconSearch, IconSearch,
IconPlusCircle,
IconArrowUp, IconArrowUp,
IconArrowDown, IconArrowDown,
IconHome, IconHome,
@@ -202,10 +201,6 @@ const JobGrid = () => {
return ( return (
<div className="jobGrid"> <div className="jobGrid">
<div className="jobGrid__topbar"> <div className="jobGrid__topbar">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
<Input <Input
className="jobGrid__topbar__search" className="jobGrid__topbar__search"
prefix={<IconSearch />} prefix={<IconSearch />}

View File

@@ -1,18 +1,33 @@
@import '../../cards/DashboardCardColors.less'; @import '../../cards/DashboardCardColors.less';
@import '../../../tokens.less';
.jobGrid { .jobGrid {
display: flex;
flex-direction: column;
flex: 1;
&__topbar {
display: flex;
align-items: center;
gap: @space-3;
margin-bottom: @space-4;
flex-wrap: wrap;
&__search {
flex: 1;
min-width: 160px;
}
}
&__card { &__card {
height: 100%; height: 100%;
transition: transform 0.2s, box-shadow 0.2s; background-color: @color-surface !important;
background-color: rgba(36, 36, 36, 0.9); border: 1px solid @color-border !important;
backdrop-filter: blur(8px); border-radius: @radius-card !important;
border: 1px solid var(--semi-color-border); transition: transform @transition-card, box-shadow @transition-card;
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
&:hover { &:hover {
transform: translateY(-4px); box-shadow: 0 4px 20px -4px rgba(0,0,0,0.5);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
} }
&__header { &__header {
@@ -35,10 +50,10 @@
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
background-color: var(--semi-color-text-3); background-color: rgba(251,113,133,0.7);
&--active { &--active {
background-color: #21aa21; background-color: rgba(52,211,153,0.8);
} }
} }
@@ -52,21 +67,21 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
background: rgba(255, 255, 255, 0.04); background: rgba(255,255,255,0.04);
border: 1px solid transparent; border: 1px solid transparent;
border-radius: var(--semi-border-radius-small); border-radius: @radius-chip;
padding: 10px 4px 8px; padding: 10px 4px 8px;
&__number { &__number {
font-size: 22px; font-size: 22px;
font-weight: 600; font-weight: 600;
color: var(--semi-color-text-0); color: @color-text;
line-height: 1.2; line-height: 1.2;
} }
&__label { &__label {
font-size: 11px; font-size: @text-xs;
color: var(--semi-color-text-3); color: @color-faint;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 3px; gap: 3px;
@@ -102,59 +117,25 @@
} }
} }
&__topbar {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 8px;
margin-bottom: 16px;
.jobGrid__topbar__search {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
flex-wrap: wrap;
.semi-button:first-child {
flex-shrink: 0;
}
.jobGrid__topbar__search {
flex: 1;
min-width: 160px;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
}
&__title { &__title {
margin-bottom: 0 !important; color: @color-text !important;
} }
&__actions { &__actions {
display: flex; display: flex;
gap: 6px; gap: 4px;
flex-shrink: 0;
} }
&__pagination { &__pagination {
margin-top: 2rem; margin-top: @space-4;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
} }
.jobPopoverContent { .jobPopoverContent {
padding: .4rem; font-size: @text-sm;
color: var(--semi-color-white); padding: 4px 8px;
color: @color-text;
} }

View File

@@ -10,34 +10,16 @@ import {
parseString, parseString,
parseNullableBoolean, parseNullableBoolean,
} from '../../../hooks/useSearchParamState.js'; } from '../../../hooks/useSearchParamState.js';
import { import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
Card,
Col,
Row,
Image,
Button,
Typography,
Pagination,
Toast,
Divider,
Input,
Select,
Empty,
Radio,
RadioGroup,
Space,
} from '@douyinfe/semi-ui-19';
import { import {
IconBriefcase, IconBriefcase,
IconCart, IconCart,
IconClock,
IconDelete, IconDelete,
IconLink, IconLink,
IconMapPin, IconMapPin,
IconStar, IconStar,
IconStarStroked, IconStarStroked,
IconSearch, IconSearch,
IconActivity,
IconEyeOpened, IconEyeOpened,
IconArrowUp, IconArrowUp,
IconArrowDown, IconArrowDown,
@@ -53,8 +35,6 @@ import { debounce } from '../../../utils';
import './ListingsGrid.less'; import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
const { Text } = Typography;
const ListingsGrid = () => { const ListingsGrid = () => {
const listingsData = useSelector((state) => state.listingsData); const listingsData = useSelector((state) => state.listingsData);
const providers = useSelector((state) => state.provider); const providers = useSelector((state) => state.provider);
@@ -137,10 +117,6 @@ const ListingsGrid = () => {
} }
}; };
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return ( return (
<div className="listingsGrid"> <div className="listingsGrid">
<div className="listingsGrid__topbar"> <div className="listingsGrid__topbar">
@@ -238,111 +214,107 @@ const ListingsGrid = () => {
description="No listings available yet..." description="No listings available yet..."
/> />
)} )}
<Row gutter={[16, 16]}> <div className="listingsGrid__grid">
{(listingsData?.result || []).map((item) => ( {(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}> <div
<Card key={item.id}
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`} className="listingsGrid__card"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)} role="button"
cover={ tabIndex={0}
<div style={{ position: 'relative' }}> onClick={() => navigate(`/listings/listing/${item.id}`)}
<div className="listingsGrid__imageContainer"> onKeyDown={(e) => {
<Image if (e.key === 'Enter' || e.key === ' ') navigate(`/listings/listing/${item.id}`);
src={item.image_url || no_image} }}
fallback={no_image} >
width="100%" <div className="listingsGrid__card__image-wrapper">
height={180} <img
style={{ objectFit: 'cover' }} src={item.image_url || no_image}
preview={false} alt={item.title}
/> onError={(e) => {
<Button e.target.src = no_image;
icon={ }}
item.isWatched === 1 ? ( />
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} /> {!item.is_active && (
) : ( <div className="listingsGrid__card__inactive-watermark">
<IconStarStroked /> <span>Inactive</span>
)
}
theme="light"
shape="circle"
size="small"
className="listingsGrid__watchButton"
onClick={(e) => handleWatch(e, item)}
/>
</div>
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
</div>
}
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<div className="listingsGrid__price">
<IconCart size="small" />
{item.price}
</div>
<div className="listingsGrid__meta">
<Text
type="secondary"
icon={<IconMapPin />}
size="small"
ellipsis={{ showTooltip: true }}
style={{ width: '100%' }}
>
{item.address || 'No address provided'}
</Text>
<Space spacing={12} wrap>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
</Space>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated
</Text>
)}
</div>
<Divider margin=".6rem" />
<div className="listingsGrid__actions">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink />
</a>
</div>
<Button
type="secondary"
size="small"
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"
size="small"
onClick={(e) => {
e.stopPropagation();
setListingToDelete(item.id);
setDeleteModalVisible(true);
}}
icon={<IconDelete />}
/>
</div> </div>
)}
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => handleWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</div>
<div className="listingsGrid__card__body">
<div className="listingsGrid__card__title" title={item.title}>
{item.title}
</div> </div>
</Card> {item.price && (
</Col> <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">
<IconBriefcase />
{item.provider}
</div>
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
</div>
<div className="listingsGrid__card__actions" onClick={(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');
}}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
navigate(`/listings/listing/${item.id}`);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
setListingToDelete(item.id);
setDeleteModalVisible(true);
}}
/>
</Tooltip>
</div>
</div>
))} ))}
</Row> </div>
{(listingsData?.result || []).length > 0 && ( {(listingsData?.result || []).length > 0 && (
<div className="listingsGrid__pagination"> <div className="listingsGrid__pagination">
<Pagination <Pagination

View File

@@ -1,22 +1,16 @@
@import '../../cards/DashboardCardColors.less'; @import '../../../tokens.less';
.listingsGrid { .listingsGrid {
&__imageContainer {
position: relative;
height: 180px;
overflow: hidden;
}
&__topbar { &__topbar {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 8px; gap: @space-3;
margin-bottom: 16px; margin-bottom: @space-4;
flex-wrap: wrap;
.listingsGrid__topbar__search { &__search {
flex: 1;
min-width: 200px; min-width: 200px;
flex: 1;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
@@ -37,149 +31,151 @@
} }
} }
&__watchButton { &__grid {
position: absolute; display: grid;
top: 8px; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
right: 8px; gap: 12px;
background-color: white !important;
box-shadow: var(--semi-shadow-elevated);
&:hover {
background-color: var(--semi-color-fill-0) !important;
}
}
&__statusTag {
position: absolute;
bottom: 8px;
left: 8px;
} }
&__card { &__card {
height: 100%; background: @color-elevated !important;
transition: transform 0.2s, box-shadow 0.2s; border: 1px solid @color-border !important;
background-color: rgba(36, 36, 36, 0.9); border-radius: @radius-card !important;
backdrop-filter: blur(8px); overflow: hidden;
border: 1px solid var(--semi-color-border); transition: transform @transition-card, box-shadow @transition-card;
display: flex;
flex-direction: column;
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-2px);
box-shadow: var(--semi-shadow-elevated); box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
background-color: rgba(36, 36, 36, 1);
} }
&--inactive { &__image-wrapper {
position: relative;
height: 160px;
overflow: hidden;
flex-shrink: 0;
.listingsGrid__imageContainer, img {
.listingsGrid__content { width: 100%;
opacity: 0.6; height: 100%;
object-fit: cover;
display: block;
}
}
&__inactive-watermark {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0,0,0,0.35);
span {
font-size: 18px;
font-weight: 800;
color: rgba(251,113,133,0.9);
text-transform: uppercase;
letter-spacing: 0.15em;
transform: rotate(-30deg);
border: 2px solid rgba(251,113,133,0.5);
padding: 4px 12px;
border-radius: @radius-chip;
backdrop-filter: blur(2px);
}
}
&__star {
position: absolute;
top: 8px;
right: 8px;
background: rgba(0,0,0,0.5);
border: none;
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background @transition-fast;
padding: 0;
&:hover {
background: rgba(0,0,0,0.75);
}
svg {
color: @color-accent;
font-size: 14px;
}
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
&__title {
font-weight: 700;
font-size: @text-sm;
color: @color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__price {
font-size: @text-base;
font-weight: 600;
color: @color-success;
display: flex;
align-items: center;
gap: 4px;
}
&__meta {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
.semi-icon {
font-size: 11px;
color: @color-faint;
}
}
&__provider {
font-size: @text-xs;
color: @color-faint;
}
&__actions {
display: flex;
justify-content: space-around;
padding: 8px 12px;
border-top: 1px solid @color-border;
gap: 4px;
margin-top: auto;
button {
flex: 1;
border: none !important;
border-radius: @radius-chip !important;
} }
} }
} }
&__inactiveOverlay {
position: absolute;
top: 70px;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 10;
color: var(--semi-color-danger);
font-weight: bold;
font-size: 1.3rem;
text-transform: uppercase;
transform: rotate(-30deg);
padding: 5px;
max-height: fit-content;
margin: auto;
}
&__titleLink {
color: inherit;
text-decoration: none;
&:hover {
color: var(--semi-color-primary);
}
}
&__title {
display: block;
height: 1.5em;
}
&__pagination { &__pagination {
margin-top: 2rem; margin-top: @space-4;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
&__price {
font-size: 18px;
font-weight: 700;
color: @color-green-text;
display: flex;
align-items: center;
gap: 5px;
margin: 8px 0 6px;
}
&__meta {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
}
&__setupButton {
margin-bottom: 1rem;
}
&__linkButton {
background: var(--semi-color-primary);
font-size: 14px;
line-height: 20px;
font-weight: 600;
height: 24px;
width: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 3px;
a {
color: white;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
&:hover {
background: var(--semi-color-primary-hover);
}
}
// Ensure icons and text are vertically aligned
.semi-typography {
display: inline-flex;
align-items: center;
.semi-typography-icon {
display: flex;
align-items: center;
margin-top: 1px; // Minor nudge if needed, but flex should handle most
}
}
} }

View File

@@ -3,13 +3,16 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { Typography } from '@douyinfe/semi-ui-19'; import './Headline.less';
export default function Headline({ text, size = 3 } = {}) { export default function Headline({ text, actions } = {}) {
const { Title } = Typography;
return ( return (
<Title heading={size} style={{ marginBottom: '1rem' }}> <div className="page-heading">
{text} <div className="page-heading__row">
</Title> <h1 className="page-heading__title">{text}</h1>
{actions && <div>{actions}</div>}
</div>
<div className="page-heading__line" />
</div>
); );
} }

View File

@@ -0,0 +1,27 @@
@import '../../tokens.less';
.page-heading {
margin-bottom: @space-6;
margin-top: 0;
&__row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
&__title {
font-size: @text-lg !important;
font-weight: 700 !important;
color: @color-text !important;
margin: 0 !important;
line-height: 1.2;
}
&__line {
height: 1px;
background: linear-gradient(90deg, rgba(224,74,56,0.5) 0%, rgba(224,74,56,0) 100%);
width: 100%;
}
}

View File

@@ -3,25 +3,20 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { Button } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr'; import { xhrPost } from '../../services/xhr';
import { IconUser } from '@douyinfe/semi-icons'; import { IconUser } from '@douyinfe/semi-icons';
const Logout = function Logout({ text }) { const Logout = function Logout({ text }) {
const handleLogout = async () => {
await xhrPost('/api/login/logout');
location.reload();
};
return ( return (
<div> <button className={`navigate__logout-btn${!text ? ' navigate__logout-btn--icon-only' : ''}`} onClick={handleLogout}>
<Button <IconUser size="default" />
icon={<IconUser />} {text && 'Logout'}
type="danger" </button>
theme="solid"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
>
{text && 'Logout'}
</Button>
</div>
); );
}; };

View File

@@ -1,11 +1,218 @@
@import '../../tokens.less';
.navigate { .navigate {
&__footer { display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: @color-surface;
border-right: 1px solid @color-border;
transition: @transition-sidebar;
overflow: hidden;
&__header {
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
flex-direction: column; padding: 20px 16px 16px;
gap: 0.5rem; min-height: 64px;
width: 100%; flex-shrink: 0;
display: flex;
padding-bottom: 12px; img {
transition: width @transition-fast, opacity @transition-fast;
}
} }
}
&__footer {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
padding: 1rem 8px 10px !important;
margin-top: auto;
flex-shrink: 0;
border-top: 1px solid @color-border;
&--collapsed {
flex-direction: column;
align-items: center;
justify-content: center;
padding: 1rem 8px 10px !important;
gap: 6px;
}
}
&__logout-btn {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 10px;
border: 1px solid rgba(251,113,133,0.25);
background: rgba(251,113,133,0.06);
color: #fb7185;
border-radius: @radius-btn;
cursor: pointer;
font-size: @text-sm;
font-weight: 500;
font-family: @font-ui;
transition: background @transition-fast, border-color @transition-fast;
white-space: nowrap;
overflow: hidden;
&:hover {
background: rgba(251,113,133,0.12);
border-color: rgba(251,113,133,0.4);
}
&--icon-only {
flex: none;
width: 32px;
height: 32px;
justify-content: center;
padding: 0;
border: none;
background: transparent;
border-radius: @radius-btn;
&:hover {
background: rgba(251,113,133,0.08);
}
}
}
&__toggle-btn {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
width: 32px;
flex-shrink: 0;
border: none;
background: transparent;
color: @color-faint;
cursor: pointer;
border-radius: @radius-btn;
transition: background @transition-fast, color @transition-fast;
&:hover {
background: rgba(255,255,255,0.06);
color: @color-muted;
}
}
}
// Semi Nav overrides
.semi-navigation {
background: @color-surface !important;
border-right: none !important;
}
.semi-navigation-item {
border-radius: @radius-btn !important;
color: @color-muted !important;
transition: background @transition-fast, color @transition-fast !important;
margin: 2px 8px !important;
&:hover {
color: @color-text !important;
}
&.semi-navigation-item-selected,
&[aria-selected="true"] {
background: rgba(224,74,56,0.12) !important;
border: 1px solid rgba(224,74,56,0.25) !important;
color: @color-text !important;
.semi-navigation-item-icon {
color: @color-accent !important;
}
}
}
.semi-navigation-sub-title {
color: @color-muted !important;
}
// Collapsed state — icons perfectly centered
.semi-navigation-collapsed {
// Text span is display:block and takes up flex space — must be removed so justify-content:center works
.semi-navigation-item-text {
display: none !important;
}
.semi-navigation-item,
.semi-navigation-sub-title {
margin: 2px 0 !important;
padding: 0 !important;
width: 100% !important;
height: 36px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
.semi-navigation-item-inner,
.semi-navigation-sub-title-inner {
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 100% !important;
min-width: 0 !important;
}
// Semi adds margin-right to icons for text spacing — remove it when collapsed
.semi-navigation-item-icon,
.semi-navigation-sub-title-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
margin: 0 !important;
margin-right: 0 !important;
padding: 0 !important;
width: auto !important;
min-width: 0 !important;
}
}
// Semi Nav.Footer — full width, no extra padding (our BEM class controls it)
.semi-navigation-footer {
width: 100% !important;
box-sizing: border-box !important;
}
// Collapsed submenu popup — actual class used by Semi UI is .semi-navigation-popover
.semi-navigation-popover {
background: @color-elevated !important;
border: 1px solid @color-border !important;
border-radius: @radius-card !important;
box-shadow: 0 8px 24px rgba(0,0,0,0.5) !important;
overflow: hidden !important;
.semi-navigation-item {
margin: 2px 6px !important;
color: @color-muted !important;
border: none !important;
border-radius: @radius-btn !important;
&:hover {
background: rgba(255,255,255,0.06) !important;
color: @color-text !important;
}
&.semi-navigation-item-selected,
&.semi-dropdown-item-active {
background: rgba(224,74,56,0.10) !important;
border: none !important;
color: @color-text !important;
.semi-navigation-item-icon {
color: @color-accent !important;
}
}
}
}

View File

@@ -4,7 +4,7 @@
*/ */
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Button, Nav } from '@douyinfe/semi-ui-19'; import { Nav } from '@douyinfe/semi-ui-19';
import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons'; import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons';
import logoWhite from '../../assets/logo_white.png'; import logoWhite from '../../assets/logo_white.png';
import heart from '../../assets/heart.png'; import heart from '../../assets/heart.png';
@@ -65,20 +65,32 @@ export default function Navigation({ isAdmin }) {
return '/' + split[0]; return '/' + split[0];
} }
const sidebarWidth = collapsed ? '60px' : '220px';
return ( return (
<Nav <Nav
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }} style={{ height: '100%', width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
items={items} items={items}
isCollapsed={collapsed} isCollapsed={collapsed}
selectedKeys={[parsePathName(location.pathname)]} selectedKeys={[parsePathName(location.pathname)]}
onSelect={(key) => { onSelect={(key) => {
navigate(key.itemKey); navigate(key.itemKey);
}} }}
header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />} header={
<div className="navigate__header">
<img src={collapsed ? heart : logoWhite} width={collapsed ? 30 : 160} alt="Fredy Logo" />
</div>
}
footer={ footer={
<Nav.Footer className="navigate__footer"> <Nav.Footer className={`navigate__footer${collapsed ? ' navigate__footer--collapsed' : ''}`}>
<Logout text={!collapsed} /> <Logout text={!collapsed} />
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} /> <button
className="navigate__toggle-btn"
onClick={() => setCollapsed(!collapsed)}
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<IconSidebar size="default" />
</button>
</Nav.Footer> </Nav.Footer>
} }
/> />

View File

@@ -1,7 +1,89 @@
@import '../../tokens.less';
.segmentParts { .segmentParts {
border: 1px solid #323232 !important; background: rgba(255,255,255,0.03) !important;
border-radius: .9rem !important; border: 1px solid @color-border !important;
color: rgba(var(--semi-grey-8), 1); border-radius: @radius-card !important;
background: rgb(53, 54, 60); margin-bottom: @space-4;
margin: 0 0 1rem 0;
// Semi Card header
.semi-card-header {
border-bottom: 1px solid @color-border !important;
padding: 16px 20px !important;
}
.semi-card-header-wrapper {
padding: 0 !important;
}
.semi-card-meta-title {
font-weight: 700 !important;
color: @color-text !important;
font-size: @text-base !important;
}
.semi-card-meta-description {
color: #b8b8b8 !important;
font-size: @text-sm !important;
margin-top: 2px;
}
.semi-card-body {
padding: 16px 20px !important;
}
// Semi input focus — subtle, not accent
.semi-input-wrapper:focus-within,
.semi-select:focus-within {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
// Icon in card header
.semi-card-meta-avatar {
color: @color-accent !important;
display: flex;
align-items: center;
}
// Inputs inside segment cards
.semi-input,
.semi-input-number-wrapper {
background: rgba(255,255,255,0.06) !important;
border: 1px solid rgba(255,255,255,0.10) !important;
border-radius: @radius-input !important;
}
// TagInput
.semi-tagInput-wrapper {
background: transparent !important;
border: 1px solid rgba(255,255,255,0.12) !important;
border-radius: @radius-input !important;
min-height: 38px;
outline: none !important;
&:focus-within {
border-color: @color-border-bright !important;
box-shadow: none !important;
}
}
.semi-tagInput {
background: transparent !important;
}
// Tag chips inside TagInput
.semi-tag {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
color: @color-text !important;
border-radius: @radius-chip !important;
font-size: @text-sm !important;
height: 24px !important;
line-height: 22px !important;
}
.semi-tag-close {
color: @color-muted !important;
&:hover {
color: @color-text !important;
background: transparent !important;
}
}
} }

View File

@@ -5,15 +5,11 @@
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations'; import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import { format } from '../../services/time/timeService'; import { format } from '../../services/time/timeService';
import { Table, Button, Empty } from '@douyinfe/semi-ui-19'; import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
const empty = ( const empty = (
<Empty <Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description={'No users found.'}
/>
); );
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) { export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
@@ -23,47 +19,73 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
empty={empty} empty={empty}
columns={[ columns={[
{ {
title: 'Username', title: 'User',
dataIndex: 'username', dataIndex: 'username',
render: (value, record) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ color: '#efefef', fontWeight: 500 }}>{value}</span>
{record.isAdmin && (
<Tag
size="small"
style={{
background: 'rgba(224,74,56,0.12)',
border: '1px solid rgba(224,74,56,0.35)',
color: '#e04a38',
borderRadius: 9999,
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.04em',
padding: '0 8px',
}}
>
ADMIN
</Tag>
)}
</div>
),
}, },
{ {
title: 'Last login', title: 'Last login',
dataIndex: 'lastLogin', dataIndex: 'lastLogin',
render: (value) => { render: (value) => format(value),
return format(value);
},
}, },
{ {
title: 'Number of jobs', title: 'Jobs',
dataIndex: 'numberOfJobs', dataIndex: 'numberOfJobs',
}, },
{ {
title: 'MCP Token', title: 'MCP Token',
dataIndex: 'mcpToken', dataIndex: 'mcpToken',
render: (value) => { render: (value) => (
return ( <span
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}> style={{
{value || '---'} fontFamily: 'JetBrains Mono, monospace',
</span> fontSize: '0.85em',
); wordBreak: 'break-all',
}, color: '#505050',
}}
>
{value || '---'}
</span>
),
}, },
{ {
title: '', title: '',
dataIndex: 'tools', dataIndex: 'tools',
render: (value, user) => { render: (_, record) => (
return ( <div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
<div style={{ float: 'right' }}> <Button
<Button style={{
type="danger" background: 'transparent',
icon={<IconDelete />} border: '1px solid rgba(251,113,133,0.2)',
onClick={() => onUserRemoval(user.id)} color: '#fb7185',
style={{ marginRight: '1rem' }} }}
/> icon={<IconDelete />}
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} /> onClick={() => onUserRemoval(record.id)}
</div> />
); <Button type="primary" theme="solid" icon={<IconEdit />} onClick={() => onUserEdit(record.id)} />
}, </div>
),
}, },
]} ]}
dataSource={user} dataSource={user}

69
ui/src/tokens.less Normal file
View File

@@ -0,0 +1,69 @@
// Backgrounds
@color-base: #0d0d0d;
@color-surface: #161616;
@color-elevated: #1e1e1e;
@color-border: #2a2a2a;
@color-border-bright: #383838;
// Accent
@color-accent: #e04a38;
@color-accent-dim: #c13827;
@color-accent-glow: rgba(224, 74, 56, 0.13);
// Text
@color-text: #efefef;
@color-muted: #909090;
@color-faint: #505050;
// Semantic
@color-success: #34d399;
@color-success-dim: #065f46;
@color-success-active: #21aa21;
@color-error: #fb7185;
@color-error-dim: #881337;
@color-warning: #fbbf24;
@color-info: #60a5fa;
// Fill overlays
@color-fill-subtle: rgba(255, 255, 255, 0.04);
@color-fill-overlay: rgba(255, 255, 255, 0.08);
// KPI card accents
@color-blue-text: #60a5fa; @color-blue-border: #3b6ea8; @color-blue-bg: rgba(96,165,250,0.10);
@color-orange-text: #fb923c; @color-orange-border: #c2622a; @color-orange-bg: rgba(251,146,60,0.10);
@color-green-text: #34d399; @color-green-border: #2a8a61; @color-green-bg: rgba(52,211,153,0.10);
@color-purple-text: #a78bfa; @color-purple-border: #6d4fc2; @color-purple-bg: rgba(167,139,250,0.10);
@color-gray-text: #94a3b8; @color-gray-border: #323a47; @color-gray-bg: rgba(148,163,184,0.10);
// Typography
@font-ui: 'Outfit', system-ui, sans-serif;
@font-mono: 'JetBrains Mono', monospace;
@text-xs: 11px;
@text-sm: 12px;
@text-base: 14px;
@text-md: 16px;
@text-lg: 20px;
@text-xl: 24px;
// Spacing
@space-1: 4px;
@space-2: 8px;
@space-3: 12px;
@space-4: 16px;
@space-5: 20px;
@space-6: 24px;
@space-8: 32px;
@space-12: 48px;
// Radius
@radius-input: 10px;
@radius-card: 10px;
@radius-btn: 6px;
@radius-pill: 9999px;
@radius-chip: 4px;
// Transitions
@transition-fast: 0.15s ease-in-out;
@transition-card: 0.18s ease-in-out;
@transition-sidebar: width 0.25s ease-in-out;

View File

@@ -4,7 +4,7 @@
*/ */
import React from 'react'; import React from 'react';
import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19'; import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
import { import {
IconTerminal, IconTerminal,
IconStar, IconStar,
@@ -20,6 +20,7 @@ import {
import { useSelector, useActions } from '../../services/state/store'; import { useSelector, useActions } from '../../services/state/store';
import KpiCard from '../../components/cards/KpiCard.jsx'; import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx'; import PieChartCard from '../../components/cards/PieChartCard.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Dashboard.less'; import './Dashboard.less';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
@@ -34,11 +35,12 @@ export default function Dashboard() {
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 }; const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
const pieData = dashboard?.pie || []; const pieData = dashboard?.pie || [];
const { Text } = Typography;
return ( return (
<div className="dashboard"> <div className="dashboard">
<Text className="dashboard__section-label">General</Text> <Headline text="Dashboard" />
<div className="dashboard__section-label">General</div>
<Row gutter={[16, 16]} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard <KpiCard
@@ -51,7 +53,6 @@ export default function Dashboard() {
<Col xs={24} sm={12} md={12} lg={6} xl={6}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard <KpiCard
title="Last Search" title="Last Search"
valueFontSize="14px"
value={ value={
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0 dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
? '---' ? '---'
@@ -69,7 +70,6 @@ export default function Dashboard() {
? '---' ? '---'
: format(dashboard?.general?.nextRun) : format(dashboard?.general?.nextRun)
} }
valueFontSize="14px"
icon={<IconDoubleChevronRight />} icon={<IconDoubleChevronRight />}
description="Next execution timestamp" description="Next execution timestamp"
/> />
@@ -96,7 +96,7 @@ export default function Dashboard() {
</Col> </Col>
</Row> </Row>
<Text className="dashboard__section-label">Overview</Text> <div className="dashboard__section-label">Overview</div>
<Row gutter={[16, 16]} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col xs={24} sm={12} md={12} lg={6} xl={6}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<KpiCard <KpiCard
@@ -132,10 +132,9 @@ export default function Dashboard() {
value={`${ value={`${
!kpis.medianPriceOfListings !kpis.medianPriceOfListings
? '---' ? '---'
: new Intl.NumberFormat('de-DE', { : new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
style: 'currency', kpis.medianPriceOfListings,
currency: 'EUR', )
}).format(kpis.medianPriceOfListings)
}`} }`}
icon={<IconNoteMoney />} icon={<IconNoteMoney />}
description="Median Price of listings" description="Median Price of listings"
@@ -143,7 +142,7 @@ export default function Dashboard() {
</Col> </Col>
</Row> </Row>
<Text className="dashboard__section-label">Provider Insights</Text> <div className="dashboard__section-label">Provider Insights</div>
<div className="dashboard__pie-wrapper"> <div className="dashboard__pie-wrapper">
<PieChartCard data={pieData} /> <PieChartCard data={pieData} />
</div> </div>

View File

@@ -1,3 +1,5 @@
@import '../../tokens.less';
.dashboard { .dashboard {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -5,13 +7,13 @@
&__section-label { &__section-label {
display: block; display: block;
font-size: 11px !important; font-size: @text-xs;
font-weight: 600 !important; font-weight: 600;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: #5a6478 !important; color: @color-faint;
margin-bottom: 10px; margin-bottom: 10px;
margin-top: 4px; margin-top: 1.5rem;
} }
&__row { &__row {
@@ -22,9 +24,8 @@
&__pie-wrapper { &__pie-wrapper {
background: #23242a; background: #23242a;
border: 1px solid #37404e; border: 1px solid #37404e;
border-radius: @radius-card;
border-radius: 10px; padding: 28px;
padding: 24px;
max-height: 320px; max-height: 320px;
flex: 1; flex: 1;
display: flex; display: flex;

View File

@@ -30,6 +30,7 @@ import {
} from '../../services/backupRestoreClient'; } from '../../services/backupRestoreClient';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons'; import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import { debounce } from '../../utils'; import { debounce } from '../../utils';
import Headline from '../../components/headline/Headline.jsx';
import './GeneralSettings.less'; import './GeneralSettings.less';
function formatFromTimestamp(ts) { function formatFromTimestamp(ts) {
@@ -244,6 +245,7 @@ const GeneralSettings = function GeneralSettings() {
return ( return (
<div className="generalSettings"> <div className="generalSettings">
<Headline text="Settings" />
{!loading && ( {!loading && (
<> <>
<Tabs type="line"> <Tabs type="line">

View File

@@ -1,17 +1,73 @@
@import '../../tokens.less';
.generalSettings { .generalSettings {
display: flex;
flex-direction: column;
flex: 1;
&__tab-content { &__tab-content {
padding: 20px 0; padding: @space-4 0;
max-width: 860px;
} }
&__timePickerContainer { &__timePickerContainer {
display: flex; display: flex;
align-items: baseline; gap: @space-3;
gap: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center;
} }
&__save-row { &__save-row {
margin-top: 1.5rem; display: flex;
justify-content: flex-end;
margin-top: @space-2;
} }
} }
// InputNumber fix
.semi-input-number {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
color: @color-text !important;
}
.semi-input-number-button-up,
.semi-input-number-button-down {
background: rgba(255,255,255,0.06) !important;
border-color: @color-border-bright !important;
color: @color-muted !important;
&:hover {
background: rgba(255,255,255,0.12) !important;
color: @color-text !important;
}
}
// TimePicker fix — scoped so it doesn't pollute modal headers
.semi-timepicker .semi-input-wrapper,
.semi-timepicker .semi-input-inset-label-wrapper {
background: @color-elevated !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-input !important;
color: @color-text !important;
}
// Tabs styling
.semi-tabs-bar-line .semi-tabs-tab {
color: @color-faint;
font-size: @text-base;
transition: color @transition-fast;
&:hover {
color: @color-muted;
}
&.semi-tabs-tab-active {
color: @color-text !important;
}
}
.semi-tabs-bar-line .semi-tabs-ink-bar {
background-color: @color-accent !important;
height: 2px;
}

View File

@@ -3,12 +3,25 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { useNavigate } from 'react-router-dom';
import { Button } from '@douyinfe/semi-ui-19';
import { IconPlusCircle } from '@douyinfe/semi-icons';
import JobGrid from '../../components/grid/jobs/JobGrid.jsx'; import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './Jobs.less'; import './Jobs.less';
export default function Jobs() { export default function Jobs() {
const navigate = useNavigate();
return ( return (
<div className="jobs"> <div className="jobs">
<Headline
text="Jobs"
actions={
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job
</Button>
}
/>
<JobGrid /> <JobGrid />
</div> </div>
); );

View File

@@ -1,8 +1,7 @@
@import '../../tokens.less';
.jobs { .jobs {
&__newButton { display: flex;
margin-top: 1rem !important; flex-direction: column;
float: left; flex: 1;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
} }

View File

@@ -18,6 +18,7 @@ import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyin
import './JobMutation.less'; import './JobMutation.less';
import { SegmentPart } from '../../../components/segment/SegmentPart'; import { SegmentPart } from '../../../components/segment/SegmentPart';
import { import {
IconArrowLeft,
IconBell, IconBell,
IconBriefcase, IconBriefcase,
IconPaperclip, IconPaperclip,
@@ -144,7 +145,19 @@ export default function JobMutator() {
/> />
)} )}
<Headline text={jobToBeEdit ? 'Edit Job' : 'Create new Job'} /> <Headline
text={jobToBeEdit ? 'Edit Job' : 'Create new Job'}
actions={
<Button
icon={<IconArrowLeft />}
onClick={() => navigate('/jobs')}
theme="borderless"
style={{ color: '#909090' }}
>
Back
</Button>
}
/>
<form> <form>
<SegmentPart name="Name" Icon={IconPaperclip}> <SegmentPart name="Name" Icon={IconPaperclip}>
<Input <Input

View File

@@ -1,25 +1,36 @@
@import '../../../tokens.less';
.jobMutation { .jobMutation {
&__newButton { &__newButton {
float: right; float: right;
margin-bottom: 1rem; margin-bottom: @space-4;
} }
&__specFilter { &__specFilter {
display: flex; display: flex;
gap: 1.5rem; gap: @space-4;
flex-wrap: wrap; flex-wrap: wrap;
} }
&__specFilterItem { &__specFilterItem {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5rem; gap: @space-2;
flex: 1; flex: 1;
min-width: 150px; min-width: 150px;
} }
&__specFilterLabel { &__specFilterLabel {
font-weight: 500; font-weight: 500;
font-size: @text-sm;
color: @color-muted;
}
&__actions {
display: flex;
gap: @space-3;
margin-top: @space-4;
justify-content: flex-end;
} }
} }

View File

@@ -41,6 +41,7 @@ import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
import Headline from '../../components/headline/Headline.jsx';
import './ListingDetail.less'; import './ListingDetail.less';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -278,7 +279,7 @@ export default function ListingDetail() {
}, },
{ {
key: 'Provider', key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1), value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
}, },
{ {
@@ -290,40 +291,45 @@ export default function ListingDetail() {
return ( return (
<div className="listing-detail"> <div className="listing-detail">
<div className="listing-detail__back"> <Headline
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless"> text={listing?.title || 'Listing Detail'}
Back actions={
</Button> <Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
</div> Back
</Button>
}
/>
<Card className="listing-detail__card"> <Card className="listing-detail__card">
<div className="listing-detail__header"> <div className="listing-detail__header">
<Space vertical align="start" spacing="tight"> <Space align="center">
<Title heading={2} className="listing-detail__title"> <IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
{listing.title} {listing.address ? (
</Title> <a
<Space align="center"> href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(listing.address)}`}
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} /> target="_blank"
<Text type="secondary">{listing.address || 'No address provided'}</Text> rel="noopener noreferrer"
</Space> className="listing-detail__address-link"
>
{listing.address}
</a>
) : (
<Text type="secondary">No address provided</Text>
)}
</Space> </Space>
<Space wrap className="listing-detail__header-actions"> <Space wrap className="listing-detail__header-actions">
<Button <Button
icon={ icon={listing.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch} onClick={handleWatch}
theme="light" theme="borderless"
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
> >
{listing.isWatched === 1 ? 'Watched' : 'Watch'} {listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button> </Button>
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline> <a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} />
Open listing Open listing
</Text> </a>
</Space> </Space>
</div> </div>

View File

@@ -1,10 +1,8 @@
@import '../../tokens.less';
.listing-detail { .listing-detail {
padding-bottom: 2rem; padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card { &__card {
background-color: rgba(36, 36, 36, 0.9); background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
@@ -45,14 +43,6 @@
} }
} }
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container { &__image-container {
width: 100%; width: 100%;
height: 400px; height: 400px;
@@ -69,7 +59,61 @@
img { img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: contain; object-fit: cover;
}
.semi-image,
.semi-image-img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
}
&__address-link {
color: @color-muted;
text-decoration: none;
font-size: @text-base;
transition: color @transition-fast;
&:hover {
color: @color-text;
text-decoration: underline;
}
}
&__watch-btn {
color: @color-muted !important;
border: 1px solid @color-border-bright !important;
border-radius: @radius-btn !important;
&:hover {
color: @color-text !important;
background: rgba(255,255,255,0.06) !important;
}
&--active {
color: @color-accent !important;
border-color: rgba(224,74,56,0.4) !important;
background: rgba(224,74,56,0.08) !important;
}
}
&__open-btn {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
border: 1px solid @color-border-bright;
border-radius: @radius-btn;
color: @color-muted;
font-size: @text-base;
font-family: @font-ui;
font-weight: 500;
text-decoration: none;
transition: color @transition-fast, border-color @transition-fast, background @transition-fast;
&:hover {
color: @color-text;
border-color: rgba(255,255,255,0.25);
background: rgba(255,255,255,0.06);
} }
} }

View File

@@ -4,7 +4,13 @@
*/ */
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx'; import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
import Headline from '../../components/headline/Headline.jsx';
export default function Listings() { export default function Listings() {
return <ListingsGrid />; return (
<>
<Headline text="Listings" />
<ListingsGrid />
</>
);
} }

View File

@@ -21,6 +21,7 @@ import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx'; import Map from '../../components/map/Map.jsx';
import Headline from '../../components/headline/Headline.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider; const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
@@ -354,127 +355,130 @@ export default function MapView() {
}, [listings, priceRange, homeAddress, distanceFilter]); }, [listings, priceRange, homeAddress, distanceFilter]);
return ( return (
<div className="map-view-container"> <>
{!homeAddress && ( <Headline text="Map View" />
<div className="map-view-container">
{!homeAddress && (
<Banner
fullMode={true}
type="warning"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description={
<span>
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
</span>
}
/>
)}
<Banner <Banner
fullMode={true} fullMode={true}
type="warning" type="info"
bordered bordered
closeIcon={null} closeIcon={null}
style={{ marginBottom: '8px' }} style={{ marginBottom: '8px' }}
description={ description="Only listings with valid addresses are shown on this map."
<span>
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
filter.
</span>
}
/>
)}
<Banner
fullMode={true}
type="info"
bordered
closeIcon={null}
style={{ marginBottom: '8px' }}
description="Only listings with valid addresses are shown on this map."
/>
<div className="map-view-container__map-wrapper">
<Map
mapContainerRef={mapContainer}
style={style}
show3dBuildings={show3dBuildings}
onMapReady={handleMapReady}
/> />
{/* Floating filter panel */} <div className="map-view-container__map-wrapper">
<div className="map-view-container__floating-panel"> <Map
<div className="map-view-container__panel-row"> mapContainerRef={mapContainer}
<Text size="small" strong style={{ color: '#8892a4' }}> style={style}
Job show3dBuildings={show3dBuildings}
</Text> onMapReady={handleMapReady}
<Select />
placeholder="All jobs"
showClear
size="small"
onChange={(val) => setJobId(val)}
value={jobId}
style={{ width: 160 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
</div>
<div className="map-view-container__panel-row"> {/* Floating filter panel */}
<Text size="small" strong style={{ color: '#8892a4' }}> <div className="map-view-container__floating-panel">
Distance <div className="map-view-container__panel-row">
</Text> <Text size="small" strong style={{ color: '#8892a4' }}>
<Select Job
placeholder="None" </Text>
size="small" <Select
onChange={(val) => setDistanceFilter(val)} placeholder="All jobs"
value={distanceFilter} showClear
style={{ width: 100 }} size="small"
> onChange={(val) => setJobId(val)}
<Select.Option value={0}>None</Select.Option> value={jobId}
<Select.Option value={5}>5 km</Select.Option> style={{ width: 160 }}
<Select.Option value={10}>10 km</Select.Option> >
<Select.Option value={15}>15 km</Select.Option> {jobs?.map((j) => (
<Select.Option value={20}>20 km</Select.Option> <Select.Option key={j.id} value={j.id}>
<Select.Option value={25}>25 km</Select.Option> {j.name}
</Select> </Select.Option>
</div> ))}
</Select>
</div>
<div className="map-view-container__panel-row"> <div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}> <Text size="small" strong style={{ color: '#8892a4' }}>
Price () Distance
</Text> </Text>
<div className="map-view-container__price-slider"> <Select
<div className="map__rangesliderLabels"> placeholder="None"
<span>{priceRange[0]}</span> size="small"
<span>{priceRange[1]}</span> onChange={(val) => setDistanceFilter(val)}
value={distanceFilter}
style={{ width: 100 }}
>
<Select.Option value={0}>None</Select.Option>
<Select.Option value={5}>5 km</Select.Option>
<Select.Option value={10}>10 km</Select.Option>
<Select.Option value={15}>15 km</Select.Option>
<Select.Option value={20}>20 km</Select.Option>
<Select.Option value={25}>25 km</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Price ()
</Text>
<div className="map-view-container__price-slider">
<div className="map__rangesliderLabels">
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
</div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div> </div>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} /> </div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div> </div>
</div> </div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>
</div>
<div className="map-view-container__panel-row">
<Text size="small" strong style={{ color: '#8892a4' }}>
3D Buildings
</Text>
<Switch
size="small"
checked={show3dBuildings}
onChange={(v) => setShow3dBuildings(v)}
disabled={style === 'SATELLITE'}
/>
</div>
</div> </div>
</div>
<ListingDeletionModal <ListingDeletionModal
visible={deleteModalVisible} visible={deleteModalVisible}
onConfirm={confirmListingDeletion} onConfirm={confirmListingDeletion}
onCancel={() => { onCancel={() => {
setDeleteModalVisible(false); setDeleteModalVisible(false);
setListingToDelete(null); setListingToDelete(null);
}} }}
/> />
</div> </div>
</>
); );
} }

View File

@@ -3,6 +3,8 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
@import '../../tokens.less';
.map-view-container { .map-view-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -21,11 +23,11 @@
top: 12px; top: 12px;
right: 12px; right: 12px;
z-index: 10; z-index: 10;
background: rgba(13, 15, 20, 0.85); background: rgba(22, 25, 38, 0.95);
backdrop-filter: blur(12px); backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(8px);
border: 1px solid #262a3a; border: 1px solid @color-border;
border-radius: 10px; border-radius: @radius-card;
padding: 14px 16px; padding: 14px 16px;
min-width: 220px; min-width: 220px;
display: flex; display: flex;
@@ -183,13 +185,19 @@
position: absolute; position: absolute;
z-index: 3; z-index: 3;
top: 50%; top: 50%;
width: 14px; width: 12px;
height: 14px; height: 12px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border-radius: 50%; border-radius: 50%;
background: #0ab5b3; background: @color-accent;
} }
.range-slider .range-slider__range { .range-slider .range-slider__range {
background: #0ab5b3; background: @color-accent;
}
.range-slider {
background: rgba(255,255,255,0.12);
border-radius: 4px;
height: 4px !important;
} }

View File

@@ -6,9 +6,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { IconHorn } from '@douyinfe/semi-icons'; import { IconHorn } from '@douyinfe/semi-icons';
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx'; import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
import { Banner, Button, Checkbox, Space } from '@douyinfe/semi-ui-19'; import { Banner, Button, Checkbox, Space, Typography } from '@douyinfe/semi-ui-19';
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx'; import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
import Headline from '../../../components/headline/Headline.jsx';
export default function WatchlistManagement() { export default function WatchlistManagement() {
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false); const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
@@ -31,7 +30,9 @@ export default function WatchlistManagement() {
description="Youll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow." description="Youll receive notifications only for listings that are on your watch list. To add listings to it, open the 'Listings' section and tag the ones you want to follow."
/> />
<Space /> <Space />
<Headline size={5} text="Notify me when:" style={{ marginTop: '1rem' }} /> <Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me when:
</Typography.Title>
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}> <Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
Listing state changes (e.g. listing becomes inactive) Listing state changes (e.g. listing becomes inactive)
@@ -41,7 +42,9 @@ export default function WatchlistManagement() {
</Checkbox> </Checkbox>
<Space /> <Space />
<Headline size={5} text="Notify me with:" style={{ marginTop: '1rem' }} /> <Typography.Title heading={5} style={{ marginTop: '1rem' }}>
Notify me with:
</Typography.Title>
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button> <Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
<NotificationAdapterMutator <NotificationAdapterMutator

View File

@@ -30,7 +30,7 @@
border-radius: 24px; border-radius: 24px;
padding: 3rem; padding: 3rem;
width: 90%; width: 90%;
max-width: 420px; max-width: 500px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;

View File

@@ -4,16 +4,14 @@
*/ */
import React from 'react'; import React from 'react';
import { Toast, Button } from '@douyinfe/semi-ui-19';
import { Toast } from '@douyinfe/semi-ui-19'; import { IconPlus } from '@douyinfe/semi-icons';
import UserTable from '../../components/table/UserTable'; import UserTable from '../../components/table/UserTable';
import { useActions, useSelector } from '../../services/state/store'; import { useActions, useSelector } from '../../services/state/store';
import { IconPlus } from '@douyinfe/semi-icons';
import { Button } from '@douyinfe/semi-ui-19';
import UserRemovalModal from './UserRemovalModal'; import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr'; import { xhrDelete } from '../../services/xhr';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Headline from '../../components/headline/Headline.jsx';
import './Users.less'; import './Users.less';
const Users = function Users() { const Users = function Users() {
@@ -28,14 +26,13 @@ const Users = function Users() {
await actions.user.getUsers(); await actions.user.getUsers();
setLoading(false); setLoading(false);
} }
init(); init();
}, []); }, []);
const onUserRemoval = async () => { const onUserRemoval = async () => {
try { try {
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved }); await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
Toast.success('User successfully remove'); Toast.success('User successfully removed');
setUserIdToBeRemoved(null); setUserIdToBeRemoved(null);
await actions.jobsData.getJobs(); await actions.jobsData.getJobs();
await actions.user.getUsers(); await actions.user.getUsers();
@@ -46,30 +43,22 @@ const Users = function Users() {
}; };
return ( return (
<div> <div className="users">
<Headline
text="Users"
actions={
<Button type="primary" theme="solid" icon={<IconPlus />} onClick={() => navigate('/users/new')}>
New User
</Button>
}
/>
{!loading && ( {!loading && (
<React.Fragment> <React.Fragment>
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />} {userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
<Button
type="primary"
className="users__newButton"
icon={<IconPlus />}
onClick={() => navigate('/users/new')}
>
New User
</Button>
<UserTable <UserTable
user={users} user={users}
onUserEdit={(userId) => { onUserEdit={(userId) => navigate(`/users/edit/${userId}`)}
navigate(`/users/edit/${userId}`); onUserRemoval={(userId) => setUserIdToBeRemoved(userId)}
}}
onUserRemoval={(userId) => {
setUserIdToBeRemoved(userId);
//throw warning message that all jobs will be removed associated to this user
//check if at least 1 admin is available
}}
/> />
</React.Fragment> </React.Fragment>
)} )}

View File

@@ -1,8 +1,7 @@
@import '../../tokens.less';
.users { .users {
&__newButton { display: flex;
margin-top: 1rem !important; flex-direction: column;
float: left; flex: 1;
margin-bottom: 1rem !important;
margin-left: 1rem;
}
} }

View File

@@ -11,7 +11,8 @@ import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19'; import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less'; import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart'; import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons'; import { IconPlusCircle, IconArrowLeft } from '@douyinfe/semi-icons';
import Headline from '../../../components/headline/Headline.jsx';
const UserMutator = function UserMutator() { const UserMutator = function UserMutator() {
const params = useParams(); const params = useParams();
@@ -63,53 +64,70 @@ const UserMutator = function UserMutator() {
}; };
return ( return (
<form className="userMutator"> <>
<SegmentPart name="Username" helpText="The username used to login to Fredy"> <Headline
<Input text={params.userId ? 'Edit User' : 'New User'}
type="text" actions={
label="Username" <Button
maxLength={30} icon={<IconArrowLeft />}
placeholder="Username" onClick={() => navigate('/users')}
autoFocus theme="borderless"
width={6} style={{ color: '#909090' }}
value={username} >
onChange={(val) => setUsername(val)} Back
/> </Button>
</SegmentPart> }
<Divider margin="1rem" /> />
<SegmentPart name="Password" helpText="The password used to login to Fredy"> <form className="userMutator">
<Input <SegmentPart name="Username" helpText="The username used to login to Fredy">
mode="password" <Input
label="Password" type="text"
placeholder="Password" label="Username"
width={6} maxLength={30}
value={password} placeholder="Username"
onChange={(val) => setPassword(val)} autoFocus
/> width={6}
</SegmentPart> value={username}
<Divider margin="1rem" /> onChange={(val) => setUsername(val)}
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match"> />
<Input </SegmentPart>
mode="password" <Divider margin="1rem" />
label="Retype password" <SegmentPart name="Password" helpText="The password used to login to Fredy">
placeholder="Retype password" <Input
width={6} mode="password"
value={password2} label="Password"
onChange={(val) => setPassword2(val)} placeholder="Password"
/> width={6}
</SegmentPart> value={password}
<Divider margin="1rem" /> onChange={(val) => setPassword(val)}
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator"> />
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} /> </SegmentPart>
</SegmentPart> <Divider margin="1rem" />
<Divider margin="1rem" /> <SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}> <Input
Cancel mode="password"
</Button> label="Retype password"
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}> placeholder="Retype password"
Save width={6}
</Button> value={password2}
</form> onChange={(val) => setPassword2(val)}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</SegmentPart>
<Divider margin="1rem" />
<div className="userMutator__actions">
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
Cancel
</Button>
<Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
Save
</Button>
</div>
</form>
</>
); );
}; };

View File

@@ -1,3 +1,13 @@
@import '../../../tokens.less';
.userMutator { .userMutator {
margin-top: 2rem; display: flex;
flex-direction: column;
&__actions {
display: flex;
gap: @space-2;
margin-top: @space-2;
justify-content: flex-start;
}
} }