Improvements 01 28 (#264)

* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
This commit is contained in:
Christian Kellner
2026-01-28 14:27:03 +01:00
committed by GitHub
parent 3117044139
commit 472169693f
29 changed files with 999 additions and 383 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 402 KiB

After

Width:  |  Height:  |  Size: 531 KiB

View File

@@ -7,7 +7,7 @@
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1" content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1"
/> />
<meta name="google" content="notranslate" /> <meta name="google" content="notranslate" />
<meta name="apple-mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes">
<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>

View File

@@ -74,6 +74,18 @@ listingsRouter.get('/map', async (req, res) => {
res.send(); res.send();
}); });
listingsRouter.get('/:listingId', async (req, res) => {
const { listingId } = req.params;
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req));
if (!listing) {
res.statusCode = 404;
res.body = { message: 'Listing not found' };
return res.send();
}
res.body = listing;
res.send();
});
// Toggle watch state for the current user on a listing // Toggle watch state for the current user on a listing
listingsRouter.post('/watch', async (req, res) => { listingsRouter.post('/watch', async (req, res) => {
try { try {

View File

@@ -566,3 +566,29 @@ export const updateListingDistance = (id, distance) => {
{ id, distance }, { id, distance },
); );
}; };
/**
* Return a single listing by id.
*
* @param {string} id
* @param {string} userId
* @param {boolean} isAdmin
* @returns {Object|null}
*/
export const getListingById = (id, userId = null, isAdmin = false) => {
const params = { id, userId: userId || '__NO_USER__' };
let whereScoping = '';
if (!isAdmin) {
whereScoping = `AND (j.user_id = @userId OR EXISTS (SELECT 1 FROM json_each(j.shared_with_user) AS sw WHERE sw.value = @userId))`;
}
return (
SqliteConnection.query(
`SELECT l.*, j.name AS job_name, CASE WHEN wl.id IS NOT NULL THEN 1 ELSE 0 END AS isWatched
FROM listings l
LEFT JOIN jobs j ON j.id = l.job_id
LEFT JOIN watch_list wl ON wl.listing_id = l.id AND wl.user_id = @userId
WHERE l.id = @id AND l.manually_deleted = 0 ${whereScoping}`,
params,
)[0] || null
);
};

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "19.2.2", "version": "19.3.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",
@@ -72,20 +72,20 @@
"cookie-session": "2.1.1", "cookie-session": "2.1.1",
"handlebars": "4.7.8", "handlebars": "4.7.8",
"lodash": "4.17.23", "lodash": "4.17.23",
"maplibre-gl": "^5.16.0", "maplibre-gl": "^5.17.0",
"nanoid": "5.1.6", "nanoid": "5.1.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"node-mailjet": "6.0.11", "node-mailjet": "6.0.11",
"p-throttle": "^8.1.0", "p-throttle": "^8.1.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer": "^24.36.0", "puppeteer": "^24.36.1",
"puppeteer-extra": "^3.3.6", "puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2", "puppeteer-extra-plugin-stealth": "^2.11.2",
"query-string": "9.3.1", "query-string": "9.3.1",
"react": "19.2.3", "react": "19.2.4",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "19.2.3", "react-dom": "19.2.4",
"react-range-slider-input": "^3.3.2", "react-range-slider-input": "^3.3.2",
"react-router": "7.13.0", "react-router": "7.13.0",
"react-router-dom": "7.13.0", "react-router-dom": "7.13.0",

View File

@@ -19,7 +19,7 @@ import Jobs from './views/jobs/Jobs';
import './App.less'; import './App.less';
import TrackingModal from './components/tracking/TrackingModal.jsx'; import TrackingModal from './components/tracking/TrackingModal.jsx';
import { Banner, Divider } from '@douyinfe/semi-ui-19'; import { Banner } from '@douyinfe/semi-ui-19';
import VersionBanner from './components/version/VersionBanner.jsx'; import VersionBanner from './components/version/VersionBanner.jsx';
import Listings from './views/listings/Listings.jsx'; import Listings from './views/listings/Listings.jsx';
import MapView from './views/listings/Map.jsx'; import MapView from './views/listings/Map.jsx';
@@ -28,6 +28,7 @@ import { Layout } from '@douyinfe/semi-ui-19';
import FredyFooter from './components/footer/FredyFooter.jsx'; import FredyFooter from './components/footer/FredyFooter.jsx';
import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx'; import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx';
import Dashboard from './views/dashboard/Dashboard.jsx'; import Dashboard from './views/dashboard/Dashboard.jsx';
import ListingDetail from './views/listings/ListingDetail.jsx';
export default function FredyApp() { export default function FredyApp() {
const actions = useActions(); const actions = useActions();
@@ -59,7 +60,7 @@ export default function FredyApp() {
}; };
const isAdmin = () => currentUser != null && currentUser.isAdmin; const isAdmin = () => currentUser != null && currentUser.isAdmin;
const { Footer, Sider, Content } = Layout; const { Sider, Content } = Layout;
return loading ? null : needsLogin() ? ( return loading ? null : needsLogin() ? (
<Routes> <Routes>
@@ -68,11 +69,11 @@ export default function FredyApp() {
</Routes> </Routes>
) : ( ) : (
<Layout className="app"> <Layout className="app">
<Layout className="app"> <Sider>
<Sider> <Navigation isAdmin={isAdmin()} />
<Navigation isAdmin={isAdmin()} /> </Sider>
</Sider> <Layout className="app__main">
<Content> <Content className="app__content">
{versionUpdate?.newVersion && <VersionBanner />} {versionUpdate?.newVersion && <VersionBanner />}
{settings.demoMode && ( {settings.demoMode && (
<> <>
@@ -87,68 +88,64 @@ export default function FredyApp() {
</> </>
)} )}
{settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />} {settings.analyticsEnabled === null && !settings.demoMode && <TrackingModal />}
<Divider /> <Routes>
<div className="app__content"> <Route path="/403" element={<InsufficientPermission />} />
<Routes> <Route path="/jobs/new" element={<JobMutation />} />
<Route path="/403" element={<InsufficientPermission />} /> <Route path="/jobs/edit/:jobId" element={<JobMutation />} />
<Route path="/jobs/new" element={<JobMutation />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/jobs/edit/:jobId" element={<JobMutation />} /> <Route path="/jobs" element={<Jobs />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/listings" element={<Listings />} />
<Route path="/jobs" element={<Jobs />} /> <Route path="/listings/listing/:listingId" element={<ListingDetail />} />
<Route path="/listings" element={<Listings />} /> <Route path="/map" element={<MapView />} />
<Route path="/map" element={<MapView />} /> <Route path="/watchlistManagement" element={<WatchlistManagement />} />
<Route path="/watchlistManagement" element={<WatchlistManagement />} />
{/* Permission-aware routes */} {/* Permission-aware routes */}
<Route <Route
path="/users/new" path="/users/new"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users/edit/:userId" path="/users/edit/:userId"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<UserMutator /> <UserMutator />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/users" path="/users"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<Users /> <Users />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/userSettings" path="/userSettings"
element={ element={
<PermissionAwareRoute currentUser={currentUser} adminOnly={false}> <PermissionAwareRoute currentUser={currentUser} adminOnly={false}>
<UserSettings /> <UserSettings />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route <Route
path="/generalSettings" path="/generalSettings"
element={ element={
<PermissionAwareRoute currentUser={currentUser}> <PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings /> <GeneralSettings />
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</div>
</Content> </Content>
</Layout>
<Footer>
<FredyFooter /> <FredyFooter />
</Footer> </Layout>
</Layout> </Layout>
); );
} }

View File

@@ -1,47 +1,29 @@
.app { .app {
height: 100%; height: 100vh;
width: 100%; width: 100vw;
&__main {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content { &__content {
margin: 1rem; flex: 1;
overflow-y: auto;
overflow-x: hidden;
position: relative;
padding: 24px;
background-color: var(--semi-color-bg-0);
box-sizing: border-box;
@media (max-width: 768px) {
padding: 12px;
}
} }
} }
.ui.inverted.segment {
background: #31303078 !important;
}
.ui.black.label,
.ui.black.labels .label {
background-color: #31303078 !important;
}
a:link {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:visited {
color: #54a9ff;
background-color: transparent;
text-decoration: none;
}
a:hover {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
a:active {
color: #54a9ff;
background-color: transparent;
text-decoration: underline;
}
a {outline : none;}
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) { .semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
vertical-align: middle; vertical-align: middle;
} }

View File

@@ -1,92 +1,67 @@
@import "DashboardCardColors.less";
.color-variant(@bg, @border, @text) {
background-color: @bg;
border: 1px solid @border;
color: @text;
}
.dashboard-card { .dashboard-card {
box-sizing: border-box;
padding: .8rem;
border-radius: .5rem;
border-width: 1px;
font-weight: 600;
box-shadow: 0 6px 20px rgba(0,0,0,0.08);
/* Make all KPI boxes the same size regardless of content/font */
width: 100%; width: 100%;
max-width: none; height: 140px;
height: 10rem; margin-bottom: 16px;
display: flex; transition: transform 0.2s, box-shadow 0.2s;
flex-direction: column; background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
&.blue { &:hover {
.color-variant(@color-blue-bg, @color-blue-border, @color-blue-text); transform: translateY(-4px);
} background-color: rgba(36, 36, 36, 1);
&.orange { &.blue {
.color-variant(@color-orange-bg, @color-orange-border, @color-orange-text); box-shadow: 0 8px 24px -5px var(--semi-color-primary);
} }
&.orange {
&.green { box-shadow: 0 8px 24px -5px var(--semi-color-warning);
.color-variant(@color-green-bg, @color-green-border, @color-green-text); }
} &.green {
box-shadow: 0 8px 24px -5px var(--semi-color-success);
&.purple { }
.color-variant(@color-purple-bg, @color-purple-border, @color-purple-text); &.purple {
} box-shadow: 0 8px 24px -5px var(--semi-color-info);
}
&.gray { &.gray {
.color-variant(@color-gray-bg, @color-gray-border, @color-gray-text); box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4);
} }
&__header {
display: flex;
align-items: center;
gap: .6rem;
/* Keep header from growing content height */
min-height: 2rem;
overflow: hidden;
} }
&__icon { &__icon {
border-radius: .6rem; font-size: 20px;
display: grid; display: flex;
place-items: center; align-items: center;
} justify-content: center;
&__title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
&__content { &__content {
margin-top: .4rem; width: 100%;
font-size: .7rem;
flex: 1 1 auto;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
} }
&__value { &__value {
margin: 0; font-weight: 700;
font-size: 1.5rem; margin-bottom: 4px;
line-height: 1.1; color: var(--semi-color-text-0);
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
&__desc { &.blue {
opacity: .8; box-shadow: 0 4px 20px -5px var(--semi-color-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
&.orange {
box-shadow: 0 4px 20px -5px var(--semi-color-warning);
}
&.green {
box-shadow: 0 4px 20px -5px var(--semi-color-success);
}
&.purple {
box-shadow: 0 4px 20px -5px var(--semi-color-info);
}
&.gray {
box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2);
}
} }

View File

@@ -3,12 +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
*/ */
/*
* Copyright (c) 2025 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react'; import React from 'react';
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
import './DashboardCard.less'; import './DashboardCard.less';
export default function KpiCard({ export default function KpiCard({
@@ -20,21 +16,28 @@ export default function KpiCard({
color = 'gray', color = 'gray',
children, children,
}) { }) {
const { Text } = Typography;
return ( return (
<div className={`dashboard-card ${color}`}> <Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
<div className="dashboard-card__header"> <Space vertical align="start" spacing="tight" style={{ width: '100%' }}>
<div className="dashboard-card__icon">{icon}</div> <Space>
<div className="dashboard-card__title"> <div className="dashboard-card__icon">{icon}</div>
<span>{title}</span> <Text strong className="dashboard-card__title">
{title}
</Text>
</Space>
<div className="dashboard-card__content">
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</div>
{description && (
<Text size="small" type="tertiary" className="dashboard-card__desc">
{description}
</Text>
)}
</div> </div>
</div> </Space>
<div className="dashboard-card__content"> </Card>
<p className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
{value}
{children}
</p>
{description && <span className="dashboard-card__desc">{description}</span>}
</div>
</div>
); );
} }

View File

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

View File

@@ -1,20 +1,12 @@
.fredyFooter { .fredyFooter {
background:rgb(53, 54, 60); background-color: var(--semi-color-bg-1);
color: white;
display: flex; display: flex;
justify-content: space-between; justify-content: flex-end;
align-items: center; align-items: center;
height: 1.7rem; padding: 0 1rem;
border-radius: .3rem; height: 32px;
border-top: 1px solid #45464b; border-top: 1px solid var(--semi-color-border);
z-index: 1000;
&__version { position: relative;
padding-left: .5rem; flex-shrink: 0;
font-size: small;
}
&__copyRight {
padding-right: 1rem;
}
} }

View File

@@ -185,31 +185,21 @@ const JobGrid = () => {
return ( return (
<div className="jobGrid"> <div className="jobGrid">
<div style={{ display: 'flex', flexDirection: 'column' }}> <Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium">
<Button <Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
style={{ width: '7rem', margin: 0 }}
type="primary"
icon={<IconPlusCircle />}
className="jobs__newButton"
onClick={() => navigate('/jobs/new')}
>
New Job New Job
</Button> </Button>
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<div className="jobGrid__searchbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} /> <Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}> <Button
<div> icon={<IconFilter />}
<Button style={{ marginLeft: '8px' }}
icon={<IconFilter />} onClick={() => {
onClick={() => { setShowFilterBar(!showFilterBar);
setShowFilterBar(!showFilterBar); }}
}} />
/>
</div>
</Popover>
</div> </div>
</div> </Space>
{showFilterBar && ( {showFilterBar && (
<div className="jobGrid__toolbar"> <div className="jobGrid__toolbar">
@@ -277,7 +267,6 @@ const JobGrid = () => {
<Card <Card
className="jobGrid__card" className="jobGrid__card"
bodyStyle={{ padding: '16px' }} bodyStyle={{ padding: '16px' }}
headerLine={true}
title={ title={
<div className="jobGrid__header"> <div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title"> <Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
@@ -351,6 +340,8 @@ const JobGrid = () => {
<div> <div>
<Button <Button
type="primary" type="primary"
style={{ background: '#21aa21b5' }}
size="small"
theme="solid" theme="solid"
icon={<IconPlayCircle />} icon={<IconPlayCircle />}
disabled={job.isOnlyShared || job.running} disabled={job.isOnlyShared || job.running}
@@ -362,7 +353,7 @@ const JobGrid = () => {
<div> <div>
<Button <Button
type="secondary" type="secondary"
theme="solid" size="small"
icon={<IconEdit />} icon={<IconEdit />}
disabled={job.isOnlyShared} disabled={job.isOnlyShared}
onClick={() => navigate(`/jobs/edit/${job.id}`)} onClick={() => navigate(`/jobs/edit/${job.id}`)}
@@ -373,7 +364,7 @@ const JobGrid = () => {
<div> <div>
<Button <Button
type="tertiary" type="tertiary"
theme="solid" size="small"
icon={<IconCopy />} icon={<IconCopy />}
disabled={job.isOnlyShared} disabled={job.isOnlyShared}
onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })} onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })}
@@ -384,7 +375,7 @@ const JobGrid = () => {
<div> <div>
<Button <Button
type="danger" type="danger"
theme="solid" size="small"
icon={<IconDescend2 />} icon={<IconDescend2 />}
disabled={job.isOnlyShared} disabled={job.isOnlyShared}
onClick={() => onListingRemoval(job.id)} onClick={() => onListingRemoval(job.id)}
@@ -395,7 +386,7 @@ const JobGrid = () => {
<div> <div>
<Button <Button
type="danger" type="danger"
theme="solid" size="small"
icon={<IconDelete />} icon={<IconDelete />}
disabled={job.isOnlyShared} disabled={job.isOnlyShared}
onClick={() => onJobRemoval(job.id)} onClick={() => onJobRemoval(job.id)}

View File

@@ -1,11 +1,16 @@
.jobGrid { .jobGrid {
&__card { &__card {
height: 100%; height: 100%;
transition: transform 0.2s; transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated); box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1);
} }
} }
@@ -19,12 +24,14 @@
&__toolbar { &__toolbar {
&__card { &__card {
border-radius: 5px; border-radius: var(--semi-border-radius-medium);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .3rem; gap: .3rem;
background: #232429; background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem; padding: 0.5rem;
border: 1px solid var(--semi-color-border);
} }
} }

View File

@@ -32,7 +32,9 @@ import {
IconSearch, IconSearch,
IconFilter, IconFilter,
IconActivity, IconActivity,
IconEyeOpened,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom';
import no_image from '../../../assets/no_image.jpg'; import no_image from '../../../assets/no_image.jpg';
import * as timeService from '../../../services/time/timeService.js'; import * as timeService from '../../../services/time/timeService.js';
import { xhrDelete, xhrPost } from '../../../services/xhr.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js';
@@ -49,6 +51,7 @@ const ListingsGrid = () => {
const providers = useSelector((state) => state.provider); const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs); const jobs = useSelector((state) => state.jobsData.jobs);
const actions = useActions(); const actions = useActions();
const navigate = useNavigate();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const pageSize = 40; const pageSize = 40;
@@ -223,6 +226,8 @@ const ListingsGrid = () => {
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}> <Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}>
<Card <Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`} className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)}
cover={ cover={
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<div className="listingsGrid__imageContainer"> <div className="listingsGrid__imageContainer">
@@ -289,17 +294,26 @@ const ListingsGrid = () => {
</Space> </Space>
<Divider margin=".6rem" /> <Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div style={{ display: 'flex', justifyContent: 'space-between' }}>
<div className="listingsGrid__linkButton"> <div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer"> <a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink /> <IconLink />
</a> </a>
</div> </div>
<Button
type="secondary"
size="small"
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button <Button
title="Remove" title="Remove"
type="danger" type="danger"
size="small" size="small"
onClick={async () => { onClick={async (e) => {
e.stopPropagation();
try { try {
await xhrDelete('/api/listings/', { ids: [item.id] }); await xhrDelete('/api/listings/', { ids: [item.id] });
Toast.success('Listing(s) successfully removed'); Toast.success('Listing(s) successfully removed');

View File

@@ -33,11 +33,15 @@
&__card { &__card {
height: 100%; height: 100%;
transition: transform 0.2s; transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
&:hover { &:hover {
transform: translateY(-4px); transform: translateY(-4px);
box-shadow: var(--semi-shadow-elevated); box-shadow: var(--semi-shadow-elevated);
background-color: rgba(36, 36, 36, 1);
} }
&--inactive { &--inactive {
@@ -90,13 +94,15 @@
} }
&__toolbar { &__toolbar {
&__card { &__card {
border-radius: 5px; border-radius: var(--semi-border-radius-medium);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .3rem; gap: .3rem;
background: #232429; background: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
padding: 0.5rem;
border: 1px solid var(--semi-color-border);
} }
} }
@@ -105,7 +111,7 @@
} }
&__linkButton { &__linkButton {
background: var(--semi-color-fill-0); background: var(--semi-color-primary);
font-size: 14px; font-size: 14px;
line-height: 20px; line-height: 20px;
font-weight: 600; font-weight: 600;
@@ -115,5 +121,18 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 3px; 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);
}
} }
} }

View File

@@ -6,5 +6,6 @@
gap: 0.5rem; gap: 0.5rem;
width: 100%; width: 100%;
display: flex; display: flex;
padding-bottom: 12px;
} }
} }

View File

@@ -70,20 +70,18 @@ export default function Navigation({ isAdmin }) {
return ( return (
<Nav <Nav
style={{ height: '100%' }} style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
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 ? '80' : '160'} alt="Fredy Logo" />} header={<img src={collapsed ? heart : logoWhite} width={collapsed ? '30' : '120'} alt="Fredy Logo" />}
footer={ footer={
<Nav.Footer className="navigate__footer"> <Nav.Footer className="navigate__footer">
<Logout text={!collapsed} /> <Logout text={!collapsed} />
<Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}> <Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} />
{!collapsed && 'Collapse'}
</Button>
</Nav.Footer> </Nav.Footer>
} }
/> />

View File

@@ -7,8 +7,10 @@ import React from 'react';
import { Empty, Table, Button } from '@douyinfe/semi-ui-19'; import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
import { Typography } from '@douyinfe/semi-ui';
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) { export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
const { Text } = Typography;
return ( return (
<Table <Table
pagination={false} pagination={false}
@@ -22,11 +24,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } =
title: 'URL', title: 'URL',
dataIndex: 'url', dataIndex: 'url',
render: (_, data) => { render: (_, data) => {
return ( return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>;
<a href={data.url} target="_blank" rel="noopener noreferrer">
Visit site
</a>
);
}, },
}, },
{ {

View File

@@ -195,6 +195,18 @@ export const useFredyState = create(
console.error('Error while trying to get resource for api/listings. Error:', Exception); console.error('Error while trying to get resource for api/listings. Error:', Exception);
} }
}, },
async getListing(listingId) {
try {
const response = await xhrGet(`/api/listings/${listingId}`);
set((state) => ({
listingsData: { ...state.listingsData, currentListing: response.json },
}));
return response.json;
} catch (Exception) {
console.error(`Error while trying to get resource for api/listings/${listingId}. Error:`, Exception);
throw Exception;
}
},
async getListingsForMap({ jobId, minPrice, maxPrice } = {}) { async getListingsForMap({ jobId, minPrice, maxPrice } = {}) {
try { try {
const qryString = queryString.stringify( const qryString = queryString.stringify(
@@ -239,6 +251,7 @@ export const useFredyState = create(
page: 1, page: 1,
result: [], result: [],
mapListings: [], mapListings: [],
currentListing: null,
maxPrice: 0, maxPrice: 0,
}, },
generalSettings: { settings: {} }, generalSettings: { settings: {} },

View File

@@ -41,10 +41,10 @@ export default function Dashboard() {
<div className="dashboard"> <div className="dashboard">
<Headline text="Dashboard" size={3} /> <Headline text="Dashboard" size={3} />
<Row gutter={16} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="General" Icon={IconTerminal}> <SegmentPart name="General" Icon={IconTerminal}>
<Row gutter={16} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> <Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard <KpiCard
title="Search Interval" title="Search Interval"
@@ -104,7 +104,7 @@ export default function Dashboard() {
</Col> </Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="Overview" Icon={IconStar}> <SegmentPart name="Overview" Icon={IconStar}>
<Row gutter={16} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> <Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard <KpiCard
title="Jobs" title="Jobs"
@@ -147,7 +147,7 @@ export default function Dashboard() {
</Row> </Row>
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers"> <SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} /> <PieChartCard data={pieData} />
</SegmentPart> </SegmentPart>
</div> </div>
); );

View File

@@ -1,11 +1,10 @@
.dashboard { .dashboard {
&__row { &__row {
margin-bottom: 1rem; margin-bottom: 24px;
/* Ensure grid items wrap to next line on narrow screens */
flex-wrap: wrap; flex-wrap: wrap;
/* Vertical gap of 1rem between wrapped grid items (no px) */
.semi-col { .semi-col {
margin-bottom: 1rem; margin-bottom: 0; // Handled by Row gutter
} }
} }
} }

View File

@@ -0,0 +1,383 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {
Typography,
Button,
Space,
Card,
Row,
Col,
Image,
Tag,
Divider,
Descriptions,
Banner,
Spin,
Toast,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
IconMapPin,
IconCart,
IconClock,
IconBriefcase,
IconActivity,
IconLink,
IconStar,
IconStarStroked,
IconRealSize,
} from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg';
import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js';
import './ListingDetail.less';
const { Title, Text } = Typography;
const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
};
export default function ListingDetail() {
const { listingId } = useParams();
const navigate = useNavigate();
const actions = useActions();
const listing = useSelector((state) => state.listingsData.currentListing);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const mapContainer = useRef(null);
const map = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchListing() {
try {
setLoading(true);
await actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to load listing details:', e);
Toast.error('Failed to load listing details');
navigate('/listings');
} finally {
setLoading(false);
}
}
fetchListing();
}, [listingId]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
useEffect(() => {
if (loading || !listing || !mapContainer.current || !hasGeo) return;
if (map.current) {
map.current.remove();
}
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES.STANDARD,
center: [listing.longitude, listing.latitude],
zoom: 14,
cooperativeGestures: true,
});
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
new maplibregl.Marker({ color: '#3FB1CE' })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
.addTo(map.current);
if (homeAddress?.coords) {
new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
.addTo(map.current);
const bounds = getBoundsFromCoords([
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
]);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
const drawLine = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
const distance = distanceMeters(
listing.latitude,
listing.longitude,
homeAddress.coords.lat,
homeAddress.coords.lng,
);
const midpoint = [
(listing.longitude + homeAddress.coords.lng) / 2,
(listing.latitude + homeAddress.coords.lat) / 2,
];
if (map.current.getSource('route')) {
map.current.getSource('route').setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
});
} else {
map.current.addSource('route', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
},
});
map.current.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#3FB1CE',
'line-width': 4,
'line-dasharray': [2, 1],
},
filter: ['==', '$type', 'LineString'],
});
map.current.addLayer({
id: 'route-distance',
type: 'symbol',
source: 'route',
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-offset': [0, -1],
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#3FB1CE',
'text-halo-width': 2,
},
filter: ['==', '$type', 'Point'],
});
}
};
if (map.current.isStyleLoaded()) {
drawLine();
} else {
map.current.on('load', drawLine);
}
}
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [listing, loading, homeAddress]);
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
Toast.error('Failed to operate Watchlist');
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!listing) return null;
const data = [
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconRealSize />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
},
];
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch}
theme="light"
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link }} icon={<IconLink />} underline>
Open listing
</Text>
</Space>
</div>
<Row>
<Col span={24} lg={12}>
<div className="listing-detail__image-container">
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
</Text>
{listing.distance_to_destination && (
<>
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
)}
</div>
</Col>
</Row>
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
style={{ marginTop: '1rem' }}
/>
) : (
<div ref={mapContainer} className="listing-detail__map-container" />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
.listing-detail {
padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card {
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
margin-bottom: 2rem;
overflow: hidden;
.semi-card-body {
padding: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
gap: 1rem;
border-bottom: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
& > .semi-space {
width: 100%;
}
&-actions {
width: 100%;
justify-content: flex-start;
margin-top: 0.5rem;
.semi-button {
flex: 1;
}
}
}
}
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container {
width: 100%;
height: 400px;
background-color: var(--semi-color-fill-0);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
@media (max-width: 768px) {
height: 250px;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__info-section {
padding: 1.5rem;
}
&__map-container {
height: 400px;
width: 100%;
border-radius: var(--semi-border-radius-medium);
margin-top: 1rem;
border: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
height: 300px;
}
}
&__map-wrapper {
margin-top: 2rem;
margin-bottom: 3rem;
}
.info-tag {
font-size: 0.9rem;
padding: 0.2rem 0.6rem;
}
}
.listing-detail-popup {
.map-popup-content {
padding: 5px;
h4 {
margin: 5px 0;
}
}
}

View File

@@ -11,14 +11,14 @@ import { useSelector, useActions } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19'; import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons'; import { IconFilter, IconLink } from '@douyinfe/semi-icons';
import { IconDelete } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.jpg';
import RangeSlider from 'react-range-slider-input'; import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import './Map.less'; import './Map.less';
import { xhrDelete } from '../../services/xhr.js'; import { xhrDelete } from '../../services/xhr.js';
import { Link } from 'react-router'; import { Link, useNavigate } from 'react-router-dom';
const { Text } = Typography; const { Text } = Typography;
@@ -73,6 +73,7 @@ export default function MapView() {
const markers = useRef([]); const markers = useRef([]);
const homeMarker = useRef(null); const homeMarker = useRef(null);
const actions = useActions(); const actions = useActions();
const navigate = useNavigate();
const listings = useSelector((state) => state.listingsData.mapListings); const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD'); const [style, setStyle] = useState('STANDARD');
@@ -113,10 +114,15 @@ export default function MapView() {
} }
}; };
window.viewDetails = (id) => {
navigate(`/listings/listing/${id}`);
};
return () => { return () => {
delete window.deleteListing; delete window.deleteListing;
delete window.viewDetails;
}; };
}, []); }, [navigate]);
useEffect(() => { useEffect(() => {
if (map.current) return; if (map.current) return;
@@ -349,7 +355,15 @@ export default function MapView() {
} }
}; };
addCircleLayer(); const updateLayers = () => {
addCircleLayer();
};
if (map.current.isStyleLoaded()) {
updateLayers();
} else {
map.current.on('load', updateLayers);
}
filterListings().forEach((listing) => { filterListings().forEach((listing) => {
if ( if (
@@ -378,6 +392,13 @@ export default function MapView() {
${renderToString(<IconLink />)} ${renderToString(<IconLink />)}
</a> </a>
</div> </div>
<button
class="map-popup-content__detailsButton"
title="View Details"
onclick="viewDetails('${listing.id}')"
>
${renderToString(<IconEyeOpened />)}
</button>
<button <button
class="map-popup-content__deleteButton" class="map-popup-content__deleteButton"
title="Remove" title="Remove"

View File

@@ -6,8 +6,9 @@
.map-view-container { .map-view-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 120px); /* Adjust based on header/footer height */ height: 100%;
padding: 1rem; padding: 0;
box-sizing: border-box;
} }
.map-filter-bar { .map-filter-bar {
@@ -78,6 +79,29 @@
} }
} }
&__detailsButton {
background: var(--semi-color-tertiary);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-tertiary-hover);
}
svg {
fill: currentColor;
}
}
&__deleteButton { &__deleteButton {
background: var(--semi-color-danger); background: var(--semi-color-danger);
color: white; color: white;

View File

@@ -58,40 +58,46 @@ export default function Login() {
return ( return (
<div className="login"> <div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} /> <div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo /> <div className="login__loginWrapper">
<form> <div className="login__logoWrapper">
<div className="login__loginWrapper"> <Logo width={250} white />
{error && <Banner type="danger" closeIcon={null} description={error} />} </div>
<Input <form onSubmit={(e) => e.preventDefault()}>
size="large" {error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
prefix={<IconUser />} <div className="login__inputGroup">
placeholder="Username" <Input
value={username} size="large"
showClear prefix={<IconUser />}
autoFocus placeholder="Username"
onChange={(value) => setUserName(value)} value={username}
onKeyPress={async (e) => { showClear
if (e.key === 'Enter') { autoFocus
await tryLogin(); onChange={(value) => setUserName(value)}
} onKeyPress={async (e) => {
}} if (e.key === 'Enter') {
/> await tryLogin();
}
}}
/>
</div>
<Input <div className="login__inputGroup">
size="large" <Input
mode="password" size="large"
prefix={<IconLock />} mode="password"
value={password} prefix={<IconLock />}
placeholder="Password" value={password}
onChange={(value) => setPassword(value)} placeholder="Password"
onKeyPress={async (e) => { onChange={(value) => setPassword(value)}
if (e.key === 'Enter') { onKeyPress={async (e) => {
await tryLogin(); if (e.key === 'Enter') {
} await tryLogin();
}} }
/> }}
/>
</div>
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}> <Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
Login Login
</Button> </Button>
@@ -102,10 +108,11 @@ export default function Login() {
bordered bordered
closeIcon={null} closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in." description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
style={{ marginTop: '1.5rem' }}
/> />
)} )}
</div> </form>
</form> </div>
</div> </div>
); );
} }

View File

@@ -4,32 +4,80 @@
align-items: center; align-items: center;
width: 100%; width: 100%;
height: 100%; height: 100%;
overflow: hidden;
position: relative;
&__bgImage { &__bgImage {
background-size: cover; background-size: cover;
filter: blur(8px); background-position: center;
-webkit-filter: blur(8px); filter: blur(10px) brightness(0.4);
-webkit-filter: blur(10px) brightness(0.4);
position: absolute; position: absolute;
top: 0; top: -20px;
left: 0; left: -20px;
right: -20px;
bottom: -20px;
z-index: 0; z-index: 0;
right: 0;
bottom: 0;
} }
&__loginWrapper { &__loginWrapper {
border: 1px solid #555050; position: relative;
border-radius: 30px;
z-index: 1; z-index: 1;
background-color: #151313ab; background: rgba(20, 20, 25, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 3rem;
width: 90%;
max-width: 420px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 2rem; align-items: center;
gap: 1rem; box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
&__logoWrapper {
margin-bottom: 2.5rem;
display: flex;
justify-content: center;
width: 100%;
.logo {
position: static !important;
max-width: 100%;
height: auto;
}
} }
form { form {
z-index: 1; width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
&__inputGroup {
width: 100%;
}
// Mobile responsiveness
@media (max-width: 480px) {
&__loginWrapper {
padding: 2rem 1.5rem;
width: 95%;
border-radius: 20px;
background: rgba(20, 20, 25, 0.85);
&::after {
opacity: 0.2;
filter: blur(10px);
}
}
&__logoWrapper {
margin-bottom: 1.5rem;
}
} }
} }

View File

@@ -1384,6 +1384,11 @@
resolved "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz" resolved "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz"
integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==
"@maplibre/geojson-vt@^5.0.4":
version "5.0.4"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz#c5f301a5d227cecf0bf4d1ab9239b8b0b13e78fe"
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
"@maplibre/maplibre-gl-style-spec@^24.4.1": "@maplibre/maplibre-gl-style-spec@^24.4.1":
version "24.4.1" version "24.4.1"
resolved "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz" resolved "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz"
@@ -1404,16 +1409,16 @@
dependencies: dependencies:
"@mapbox/point-geometry" "^1.1.0" "@mapbox/point-geometry" "^1.1.0"
"@maplibre/vt-pbf@^4.2.0": "@maplibre/vt-pbf@^4.2.1":
version "4.2.0" version "4.2.1"
resolved "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.0.tgz" resolved "https://registry.yarnpkg.com/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz#395d97bd5de68b5efabf0d56c535163bb88f75c7"
integrity sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA== integrity sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA==
dependencies: dependencies:
"@mapbox/point-geometry" "^1.1.0" "@mapbox/point-geometry" "^1.1.0"
"@mapbox/vector-tile" "^2.0.4" "@mapbox/vector-tile" "^2.0.4"
"@types/geojson-vt" "3.2.5" "@maplibre/geojson-vt" "^5.0.4"
"@types/geojson" "^7946.0.16"
"@types/supercluster" "^7.1.3" "@types/supercluster" "^7.1.3"
geojson-vt "^4.0.2"
pbf "^4.0.1" pbf "^4.0.1"
supercluster "^8.0.1" supercluster "^8.0.1"
@@ -1460,10 +1465,10 @@
resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@puppeteer/browsers@2.11.1": "@puppeteer/browsers@2.11.2":
version "2.11.1" version "2.11.2"
resolved "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.1.tgz" resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.2.tgz#54ac339579c23014535011e6dc04bf3157705d73"
integrity sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA== integrity sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw==
dependencies: dependencies:
debug "^4.4.3" debug "^4.4.3"
extract-zip "^2.0.1" extract-zip "^2.0.1"
@@ -1746,13 +1751,6 @@
resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz"
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
"@types/geojson-vt@3.2.5":
version "3.2.5"
resolved "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz"
integrity sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==
dependencies:
"@types/geojson" "*"
"@types/geojson@*", "@types/geojson@^7946.0.16": "@types/geojson@*", "@types/geojson@^7946.0.16":
version "7946.0.16" version "7946.0.16"
resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz" resolved "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz"
@@ -3591,11 +3589,6 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz" resolved "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
geojson-vt@^4.0.2:
version "4.0.2"
resolved "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.2.tgz"
integrity sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==
get-caller-file@^2.0.5: get-caller-file@^2.0.5:
version "2.0.5" version "2.0.5"
resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz"
@@ -4578,10 +4571,10 @@ make-dir@^2.1.0:
pify "^4.0.1" pify "^4.0.1"
semver "^5.6.0" semver "^5.6.0"
maplibre-gl@^5.16.0: maplibre-gl@^5.17.0:
version "5.16.0" version "5.17.0"
resolved "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.16.0.tgz" resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.17.0.tgz#b7de18caf2c70d0ba98715803eea7f1e39581c36"
integrity sha512-/VDY89nr4jgLJyzmhy325cG6VUI02WkZ/UfVuDbG/piXzo6ODnM+omDFIwWY8tsEsBG26DNDmNMn3Y2ikHsBiA== integrity sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA==
dependencies: dependencies:
"@mapbox/geojson-rewind" "^0.5.2" "@mapbox/geojson-rewind" "^0.5.2"
"@mapbox/jsonlint-lines-primitives" "^2.0.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2"
@@ -4590,14 +4583,13 @@ maplibre-gl@^5.16.0:
"@mapbox/unitbezier" "^0.0.1" "@mapbox/unitbezier" "^0.0.1"
"@mapbox/vector-tile" "^2.0.4" "@mapbox/vector-tile" "^2.0.4"
"@mapbox/whoots-js" "^3.1.0" "@mapbox/whoots-js" "^3.1.0"
"@maplibre/geojson-vt" "^5.0.4"
"@maplibre/maplibre-gl-style-spec" "^24.4.1" "@maplibre/maplibre-gl-style-spec" "^24.4.1"
"@maplibre/mlt" "^1.1.2" "@maplibre/mlt" "^1.1.2"
"@maplibre/vt-pbf" "^4.2.0" "@maplibre/vt-pbf" "^4.2.1"
"@types/geojson" "^7946.0.16" "@types/geojson" "^7946.0.16"
"@types/geojson-vt" "3.2.5"
"@types/supercluster" "^7.1.3" "@types/supercluster" "^7.1.3"
earcut "^3.0.2" earcut "^3.0.2"
geojson-vt "^4.0.2"
gl-matrix "^3.4.4" gl-matrix "^3.4.4"
kdbush "^4.0.2" kdbush "^4.0.2"
murmurhash-js "^1.0.0" murmurhash-js "^1.0.0"
@@ -6019,12 +6011,12 @@ punycode@^2.1.0:
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
puppeteer-core@24.36.0: puppeteer-core@24.36.1:
version "24.36.0" version "24.36.1"
resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz" resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.36.1.tgz#8d60c80a27b86b3d1d948155ecab2deb404cc00f"
integrity sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg== integrity sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ==
dependencies: dependencies:
"@puppeteer/browsers" "2.11.1" "@puppeteer/browsers" "2.11.2"
chromium-bidi "13.0.1" chromium-bidi "13.0.1"
debug "^4.4.3" debug "^4.4.3"
devtools-protocol "0.0.1551306" devtools-protocol "0.0.1551306"
@@ -6079,16 +6071,16 @@ puppeteer-extra@^3.3.6:
debug "^4.1.1" debug "^4.1.1"
deepmerge "^4.2.2" deepmerge "^4.2.2"
puppeteer@^24.36.0: puppeteer@^24.36.1:
version "24.36.0" version "24.36.1"
resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz" resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.36.1.tgz#55b328215090b9617eb71ca541232c14a60c14cb"
integrity sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ== integrity sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ==
dependencies: dependencies:
"@puppeteer/browsers" "2.11.1" "@puppeteer/browsers" "2.11.2"
chromium-bidi "13.0.1" chromium-bidi "13.0.1"
cosmiconfig "^9.0.0" cosmiconfig "^9.0.0"
devtools-protocol "0.0.1551306" devtools-protocol "0.0.1551306"
puppeteer-core "24.36.0" puppeteer-core "24.36.1"
typed-query-selector "^2.12.0" typed-query-selector "^2.12.0"
qs@^6.14.1: qs@^6.14.1:
@@ -6149,10 +6141,10 @@ react-chartjs-2@^5.3.1:
resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz" resolved "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz"
integrity sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A== integrity sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==
react-dom@19.2.3: react-dom@19.2.4:
version "19.2.3" version "19.2.4"
resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591"
integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg== integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==
dependencies: dependencies:
scheduler "^0.27.0" scheduler "^0.27.0"
@@ -6213,10 +6205,10 @@ react-window@^1.8.2:
"@babel/runtime" "^7.0.0" "@babel/runtime" "^7.0.0"
memoize-one ">=3.1.1 <6" memoize-one ">=3.1.1 <6"
react@19.2.3: react@19.2.4:
version "19.2.3" version "19.2.4"
resolved "https://registry.npmjs.org/react/-/react-19.2.3.tgz" resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a"
integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==
readable-stream@^3.1.1, readable-stream@^3.4.0: readable-stream@^3.1.1, readable-stream@^3.4.0:
version "3.6.2" version "3.6.2"