mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
committed by
GitHub
parent
3117044139
commit
472169693f
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 |
@@ -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>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -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",
|
||||||
|
|||||||
127
ui/src/App.jsx
127
ui/src/App.jsx
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,5 +6,6 @@
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
padding-bottom: 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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: {} },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
383
ui/src/views/listings/ListingDetail.jsx
Normal file
383
ui/src/views/listings/ListingDetail.jsx
Normal 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} m²` : '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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
ui/src/views/listings/ListingDetail.less
Normal file
110
ui/src/views/listings/ListingDetail.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
yarn.lock
88
yarn.lock
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user