diff --git a/doc/screenshot2.png b/doc/screenshot2.png index fe22a715..5d788544 100644 Binary files a/doc/screenshot2.png and b/doc/screenshot2.png differ diff --git a/doc/screenshot3.png b/doc/screenshot3.png index 60d17722..4e89ceb0 100644 Binary files a/doc/screenshot3.png and b/doc/screenshot3.png differ diff --git a/index.html b/index.html index 9804b342..0e4f9fd7 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1" /> - + Fredy || Real Estate Finder diff --git a/lib/api/routes/listingsRouter.js b/lib/api/routes/listingsRouter.js index ef0af828..4de113d4 100644 --- a/lib/api/routes/listingsRouter.js +++ b/lib/api/routes/listingsRouter.js @@ -74,6 +74,18 @@ listingsRouter.get('/map', async (req, res) => { 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 listingsRouter.post('/watch', async (req, res) => { try { diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index e3e6b1bb..07b50d89 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -566,3 +566,29 @@ export const updateListingDistance = (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 + ); +}; diff --git a/package.json b/package.json index 48354ed9..bda93fa1 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "19.2.2", + "version": "19.3.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", @@ -72,20 +72,20 @@ "cookie-session": "2.1.1", "handlebars": "4.7.8", "lodash": "4.17.23", - "maplibre-gl": "^5.16.0", + "maplibre-gl": "^5.17.0", "nanoid": "5.1.6", "node-cron": "^4.2.1", "node-fetch": "3.3.2", "node-mailjet": "6.0.11", "p-throttle": "^8.1.0", "package-up": "^5.0.0", - "puppeteer": "^24.36.0", + "puppeteer": "^24.36.1", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", "query-string": "9.3.1", - "react": "19.2.3", + "react": "19.2.4", "react-chartjs-2": "^5.3.1", - "react-dom": "19.2.3", + "react-dom": "19.2.4", "react-range-slider-input": "^3.3.2", "react-router": "7.13.0", "react-router-dom": "7.13.0", diff --git a/ui/src/App.jsx b/ui/src/App.jsx index a1cbc907..1e65c2bb 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -19,7 +19,7 @@ import Jobs from './views/jobs/Jobs'; import './App.less'; 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 Listings from './views/listings/Listings.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 WatchlistManagement from './views/listings/management/WatchlistManagement.jsx'; import Dashboard from './views/dashboard/Dashboard.jsx'; +import ListingDetail from './views/listings/ListingDetail.jsx'; export default function FredyApp() { const actions = useActions(); @@ -59,7 +60,7 @@ export default function FredyApp() { }; const isAdmin = () => currentUser != null && currentUser.isAdmin; - const { Footer, Sider, Content } = Layout; + const { Sider, Content } = Layout; return loading ? null : needsLogin() ? ( @@ -68,11 +69,11 @@ export default function FredyApp() { ) : ( - - - - - + + + + + {versionUpdate?.newVersion && } {settings.demoMode && ( <> @@ -87,68 +88,64 @@ export default function FredyApp() { )} {settings.analyticsEnabled === null && !settings.demoMode && } - -
- - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> - {/* Permission-aware routes */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* Permission-aware routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - } /> - -
+ } /> +
-
- +
); } diff --git a/ui/src/App.less b/ui/src/App.less index 3168d09b..dbdfeec3 100644 --- a/ui/src/App.less +++ b/ui/src/App.less @@ -1,47 +1,29 @@ .app { - height: 100%; - width: 100%; + height: 100vh; + width: 100vw; + + &__main { + height: 100vh; + display: flex; + flex-direction: column; + overflow: hidden; + } &__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) { vertical-align: middle; } diff --git a/ui/src/components/cards/DashboardCard.less b/ui/src/components/cards/DashboardCard.less index f3179f7b..4e3b0edc 100644 --- a/ui/src/components/cards/DashboardCard.less +++ b/ui/src/components/cards/DashboardCard.less @@ -1,92 +1,67 @@ -@import "DashboardCardColors.less"; - -.color-variant(@bg, @border, @text) { - background-color: @bg; - border: 1px solid @border; - color: @text; -} - .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%; - max-width: none; - height: 10rem; - display: flex; - flex-direction: column; + height: 140px; + margin-bottom: 16px; + 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); - &.blue { - .color-variant(@color-blue-bg, @color-blue-border, @color-blue-text); - } + &:hover { + transform: translateY(-4px); + background-color: rgba(36, 36, 36, 1); - &.orange { - .color-variant(@color-orange-bg, @color-orange-border, @color-orange-text); - } - - &.green { - .color-variant(@color-green-bg, @color-green-border, @color-green-text); + &.blue { + box-shadow: 0 8px 24px -5px var(--semi-color-primary); + } + &.orange { + box-shadow: 0 8px 24px -5px var(--semi-color-warning); + } + &.green { + box-shadow: 0 8px 24px -5px var(--semi-color-success); + } + &.purple { + box-shadow: 0 8px 24px -5px var(--semi-color-info); + } + &.gray { + box-shadow: 0 8px 24px -5px rgba(255, 255, 255, 0.4); + } } - &.purple { - .color-variant(@color-purple-bg, @color-purple-border, @color-purple-text); + &__icon { + font-size: 20px; + display: flex; + align-items: center; + justify-content: center; } - &.gray { - .color-variant(@color-gray-bg, @color-gray-border, @color-gray-text); + &__content { + width: 100%; } - &__header { - display: flex; - align-items: center; - gap: .6rem; - /* Keep header from growing content height */ - min-height: 2rem; - overflow: hidden; + &__value { + font-weight: 700; + margin-bottom: 4px; + color: var(--semi-color-text-0); } - &__icon { - border-radius: .6rem; - display: grid; - place-items: center; + &.blue { + box-shadow: 0 4px 20px -5px var(--semi-color-primary); } - &__title { - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &.orange { + box-shadow: 0 4px 20px -5px var(--semi-color-warning); } - &__content { - margin-top: .4rem; - font-size: .7rem; - flex: 1 1 auto; - display: flex; - flex-direction: column; - justify-content: center; - overflow: hidden; + &.green { + box-shadow: 0 4px 20px -5px var(--semi-color-success); } - &__value { - margin: 0; - font-size: 1.5rem; - line-height: 1.1; - color: #fff; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &.purple { + box-shadow: 0 4px 20px -5px var(--semi-color-info); } - &__desc { - opacity: .8; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + &.gray { + box-shadow: 0 4px 20px -5px rgba(255, 255, 255, 0.2); } - } \ No newline at end of file diff --git a/ui/src/components/cards/KpiCard.jsx b/ui/src/components/cards/KpiCard.jsx index a0ab4bad..f96435ec 100644 --- a/ui/src/components/cards/KpiCard.jsx +++ b/ui/src/components/cards/KpiCard.jsx @@ -3,12 +3,8 @@ * 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 { Card, Typography, Space } from '@douyinfe/semi-ui-19'; import './DashboardCard.less'; export default function KpiCard({ @@ -20,21 +16,28 @@ export default function KpiCard({ color = 'gray', children, }) { + const { Text } = Typography; return ( -
-
-
{icon}
-
- {title} + + + +
{icon}
+ + {title} + +
+
+
+ {value} + {children} +
+ {description && ( + + {description} + + )}
-
-
-

- {value} - {children} -

- {description && {description}} -
-
+ + ); } diff --git a/ui/src/components/footer/FredyFooter.jsx b/ui/src/components/footer/FredyFooter.jsx index 43701eba..ea8b9a19 100644 --- a/ui/src/components/footer/FredyFooter.jsx +++ b/ui/src/components/footer/FredyFooter.jsx @@ -6,19 +6,23 @@ import React from 'react'; import './FredyFooter.less'; 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() { const { Text } = Typography; + const { Footer } = Layout; const version = useSelector((state) => state.versionUpdate.versionUpdate); + return ( -
-
- Fredy V{version?.localFredyVersion || 'N/A'} -
-
- Made with ❤️ -
-
+ ); } diff --git a/ui/src/components/footer/FredyFooter.less b/ui/src/components/footer/FredyFooter.less index c0558b28..037ea344 100644 --- a/ui/src/components/footer/FredyFooter.less +++ b/ui/src/components/footer/FredyFooter.less @@ -1,20 +1,12 @@ .fredyFooter { - background:rgb(53, 54, 60); - color: white; + background-color: var(--semi-color-bg-1); display: flex; - justify-content: space-between; + justify-content: flex-end; align-items: center; - height: 1.7rem; - border-radius: .3rem; - border-top: 1px solid #45464b; - - &__version { - padding-left: .5rem; - font-size: small; - - } - &__copyRight { - padding-right: 1rem; - - } + padding: 0 1rem; + height: 32px; + border-top: 1px solid var(--semi-color-border); + z-index: 1000; + position: relative; + flex-shrink: 0; } \ No newline at end of file diff --git a/ui/src/components/grid/jobs/JobGrid.jsx b/ui/src/components/grid/jobs/JobGrid.jsx index 99ec091a..f2d4e5f2 100644 --- a/ui/src/components/grid/jobs/JobGrid.jsx +++ b/ui/src/components/grid/jobs/JobGrid.jsx @@ -185,31 +185,21 @@ const JobGrid = () => { return (
-
- - -
+
} showClear placeholder="Search" onChange={handleFilterChange} /> - -
-
-
+
-
+ {showFilterBar && (
@@ -277,7 +267,6 @@ const JobGrid = () => { @@ -351,6 +340,8 @@ const JobGrid = () => { <div> <Button type="primary" + style={{ background: '#21aa21b5' }} + size="small" theme="solid" icon={<IconPlayCircle />} disabled={job.isOnlyShared || job.running} @@ -362,7 +353,7 @@ const JobGrid = () => { <div> <Button type="secondary" - theme="solid" + size="small" icon={<IconEdit />} disabled={job.isOnlyShared} onClick={() => navigate(`/jobs/edit/${job.id}`)} @@ -373,7 +364,7 @@ const JobGrid = () => { <div> <Button type="tertiary" - theme="solid" + size="small" icon={<IconCopy />} disabled={job.isOnlyShared} onClick={() => navigate('/jobs/new', { state: { cloneFrom: job.id } })} @@ -384,7 +375,7 @@ const JobGrid = () => { <div> <Button type="danger" - theme="solid" + size="small" icon={<IconDescend2 />} disabled={job.isOnlyShared} onClick={() => onListingRemoval(job.id)} @@ -395,7 +386,7 @@ const JobGrid = () => { <div> <Button type="danger" - theme="solid" + size="small" icon={<IconDelete />} disabled={job.isOnlyShared} onClick={() => onJobRemoval(job.id)} diff --git a/ui/src/components/grid/jobs/JobGrid.less b/ui/src/components/grid/jobs/JobGrid.less index 6fb2e6dc..5ce81019 100644 --- a/ui/src/components/grid/jobs/JobGrid.less +++ b/ui/src/components/grid/jobs/JobGrid.less @@ -1,11 +1,16 @@ .jobGrid { &__card { 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 { 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 { &__card { - border-radius: 5px; + border-radius: var(--semi-border-radius-medium); display: flex; flex-direction: column; 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); } } diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index e291de26..b479677f 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -32,7 +32,9 @@ import { IconSearch, IconFilter, IconActivity, + IconEyeOpened, } from '@douyinfe/semi-icons'; +import { useNavigate } from 'react-router-dom'; import no_image from '../../../assets/no_image.jpg'; import * as timeService from '../../../services/time/timeService.js'; import { xhrDelete, xhrPost } from '../../../services/xhr.js'; @@ -49,6 +51,7 @@ const ListingsGrid = () => { const providers = useSelector((state) => state.provider); const jobs = useSelector((state) => state.jobsData.jobs); const actions = useActions(); + const navigate = useNavigate(); const [page, setPage] = useState(1); 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}> <Card className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`} + style={{ cursor: 'pointer' }} + onClick={() => navigate(`/listings/listing/${item.id}`)} cover={ <div style={{ position: 'relative' }}> <div className="listingsGrid__imageContainer"> @@ -289,17 +294,26 @@ const ListingsGrid = () => { </Space> <Divider margin=".6rem" /> <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"> <IconLink /> </a> </div> + <Button + type="secondary" + size="small" + title="View Details" + onClick={() => navigate(`/listings/listing/${item.id}`)} + icon={<IconEyeOpened />} + /> + <Button title="Remove" type="danger" size="small" - onClick={async () => { + onClick={async (e) => { + e.stopPropagation(); try { await xhrDelete('/api/listings/', { ids: [item.id] }); Toast.success('Listing(s) successfully removed'); diff --git a/ui/src/components/grid/listings/ListingsGrid.less b/ui/src/components/grid/listings/ListingsGrid.less index 6fd7c1cb..d9a2473a 100644 --- a/ui/src/components/grid/listings/ListingsGrid.less +++ b/ui/src/components/grid/listings/ListingsGrid.less @@ -33,11 +33,15 @@ &__card { 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 { transform: translateY(-4px); box-shadow: var(--semi-shadow-elevated); + background-color: rgba(36, 36, 36, 1); } &--inactive { @@ -90,13 +94,15 @@ } &__toolbar { - &__card { - border-radius: 5px; + border-radius: var(--semi-border-radius-medium); display: flex; flex-direction: column; 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 { - background: var(--semi-color-fill-0); + background: var(--semi-color-primary); font-size: 14px; line-height: 20px; font-weight: 600; @@ -115,5 +121,18 @@ align-items: center; justify-content: center; border-radius: 3px; + + a { + color: white; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + } + + &:hover { + background: var(--semi-color-primary-hover); + } } } diff --git a/ui/src/components/navigation/Navigate.less b/ui/src/components/navigation/Navigate.less index d2936e4b..cd98f273 100644 --- a/ui/src/components/navigation/Navigate.less +++ b/ui/src/components/navigation/Navigate.less @@ -6,5 +6,6 @@ gap: 0.5rem; width: 100%; display: flex; + padding-bottom: 12px; } } \ No newline at end of file diff --git a/ui/src/components/navigation/Navigation.jsx b/ui/src/components/navigation/Navigation.jsx index 5b215aab..5823fa0b 100644 --- a/ui/src/components/navigation/Navigation.jsx +++ b/ui/src/components/navigation/Navigation.jsx @@ -70,20 +70,18 @@ export default function Navigation({ isAdmin }) { return ( <Nav - style={{ height: '100%' }} + style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }} items={items} isCollapsed={collapsed} selectedKeys={[parsePathName(location.pathname)]} onSelect={(key) => { 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={ <Nav.Footer className="navigate__footer"> <Logout text={!collapsed} /> - <Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)}> - {!collapsed && 'Collapse'} - </Button> + <Button icon={<IconSidebar />} onClick={() => setCollapsed(!collapsed)} /> </Nav.Footer> } /> diff --git a/ui/src/components/table/ProviderTable.jsx b/ui/src/components/table/ProviderTable.jsx index b915d792..4da52cd2 100644 --- a/ui/src/components/table/ProviderTable.jsx +++ b/ui/src/components/table/ProviderTable.jsx @@ -7,8 +7,10 @@ import React from 'react'; import { Empty, Table, Button } from '@douyinfe/semi-ui-19'; import { IconDelete, IconEdit } from '@douyinfe/semi-icons'; +import { Typography } from '@douyinfe/semi-ui'; export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) { + const { Text } = Typography; return ( <Table pagination={false} @@ -22,11 +24,7 @@ export default function ProviderTable({ providerData = [], onRemove, onEdit } = title: 'URL', dataIndex: 'url', render: (_, data) => { - return ( - <a href={data.url} target="_blank" rel="noopener noreferrer"> - Visit site - </a> - ); + return <Text link={{ href: data.url, target: '_blank' }}>Open Provider</Text>; }, }, { diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index 9ab762e9..e1e05889 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -195,6 +195,18 @@ export const useFredyState = create( 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 } = {}) { try { const qryString = queryString.stringify( @@ -239,6 +251,7 @@ export const useFredyState = create( page: 1, result: [], mapListings: [], + currentListing: null, maxPrice: 0, }, generalSettings: { settings: {} }, diff --git a/ui/src/views/dashboard/Dashboard.jsx b/ui/src/views/dashboard/Dashboard.jsx index 25388889..6dde4797 100644 --- a/ui/src/views/dashboard/Dashboard.jsx +++ b/ui/src/views/dashboard/Dashboard.jsx @@ -41,10 +41,10 @@ export default function Dashboard() { <div className="dashboard"> <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}> <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}> <KpiCard title="Search Interval" @@ -104,7 +104,7 @@ export default function Dashboard() { </Col> <Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <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}> <KpiCard title="Jobs" @@ -147,7 +147,7 @@ export default function Dashboard() { </Row> <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> </div> ); diff --git a/ui/src/views/dashboard/Dashboard.less b/ui/src/views/dashboard/Dashboard.less index e3b3c887..a97f9a17 100644 --- a/ui/src/views/dashboard/Dashboard.less +++ b/ui/src/views/dashboard/Dashboard.less @@ -1,11 +1,10 @@ .dashboard { &__row { - margin-bottom: 1rem; - /* Ensure grid items wrap to next line on narrow screens */ + margin-bottom: 24px; flex-wrap: wrap; - /* Vertical gap of 1rem between wrapped grid items (no px) */ + .semi-col { - margin-bottom: 1rem; + margin-bottom: 0; // Handled by Row gutter } } } diff --git a/ui/src/views/listings/ListingDetail.jsx b/ui/src/views/listings/ListingDetail.jsx new file mode 100644 index 00000000..ff0da5fc --- /dev/null +++ b/ui/src/views/listings/ListingDetail.jsx @@ -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} + + + + {listing.address || 'No address provided'} + + + + + } underline> + Open listing + + +
+ + + +
+ +
+ + +
+ + Details + + + {data.map((item, index) => ( + + + {item.Icon} + {item.value} + + + ))} + + + + Description + + + {listing.description || 'No description available.'} + + + {listing.distance_to_destination && ( + <> + + + + Distance to home: + {listing.distance_to_destination} m + + + )} +
+ +
+ + +
+ Location + {!hasGeo ? ( + + ) : ( +
+ )} +
+
+ ); +} diff --git a/ui/src/views/listings/ListingDetail.less b/ui/src/views/listings/ListingDetail.less new file mode 100644 index 00000000..9c692fe6 --- /dev/null +++ b/ui/src/views/listings/ListingDetail.less @@ -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; + } + } +} diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index f7cb9d0b..d9958afb 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -11,14 +11,14 @@ import { useSelector, useActions } from '../../services/state/store.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 { 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 RangeSlider from 'react-range-slider-input'; import 'react-range-slider-input/dist/style.css'; import './Map.less'; import { xhrDelete } from '../../services/xhr.js'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router-dom'; const { Text } = Typography; @@ -73,6 +73,7 @@ export default function MapView() { const markers = useRef([]); const homeMarker = useRef(null); const actions = useActions(); + const navigate = useNavigate(); const listings = useSelector((state) => state.listingsData.mapListings); const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const [style, setStyle] = useState('STANDARD'); @@ -113,10 +114,15 @@ export default function MapView() { } }; + window.viewDetails = (id) => { + navigate(`/listings/listing/${id}`); + }; + return () => { delete window.deleteListing; + delete window.viewDetails; }; - }, []); + }, [navigate]); useEffect(() => { 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) => { if ( @@ -378,6 +392,13 @@ export default function MapView() { ${renderToString()}
+ @@ -102,10 +108,11 @@ export default function Login() { bordered closeIcon={null} description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in." + style={{ marginTop: '1.5rem' }} /> )} -
- + +
); } diff --git a/ui/src/views/login/login.less b/ui/src/views/login/login.less index f6e2b568..cd735044 100644 --- a/ui/src/views/login/login.less +++ b/ui/src/views/login/login.less @@ -4,32 +4,80 @@ align-items: center; width: 100%; height: 100%; + overflow: hidden; + position: relative; &__bgImage { background-size: cover; - filter: blur(8px); - -webkit-filter: blur(8px); + background-position: center; + filter: blur(10px) brightness(0.4); + -webkit-filter: blur(10px) brightness(0.4); position: absolute; - top: 0; - left: 0; + top: -20px; + left: -20px; + right: -20px; + bottom: -20px; z-index: 0; - right: 0; - bottom: 0; } &__loginWrapper { - border: 1px solid #555050; - border-radius: 30px; - + position: relative; 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; flex-direction: column; - padding: 2rem; - gap: 1rem; + align-items: center; + 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 { - 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; + } } } diff --git a/yarn.lock b/yarn.lock index 72b6ec52..9e748bee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1384,6 +1384,11 @@ resolved "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz" 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": version "24.4.1" resolved "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.4.1.tgz" @@ -1404,16 +1409,16 @@ dependencies: "@mapbox/point-geometry" "^1.1.0" -"@maplibre/vt-pbf@^4.2.0": - version "4.2.0" - resolved "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.2.0.tgz" - integrity sha512-bxrk/kQUwWXZgmqYgwOCnZCMONCRi3MJMqJdza4T3E4AeR5i+VyMnaJ8iDWtWxdfEAJRtrzIOeJtxZSy5mFrFA== +"@maplibre/vt-pbf@^4.2.1": + version "4.2.1" + resolved "https://registry.yarnpkg.com/@maplibre/vt-pbf/-/vt-pbf-4.2.1.tgz#395d97bd5de68b5efabf0d56c535163bb88f75c7" + integrity sha512-IxZBGq/+9cqf2qdWlFuQ+ZfoMhWpxDUGQZ/poPHOJBvwMUT1GuxLo6HgYTou+xxtsOsjfbcjI8PZaPCtmt97rA== dependencies: "@mapbox/point-geometry" "^1.1.0" "@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" - geojson-vt "^4.0.2" pbf "^4.0.1" supercluster "^8.0.1" @@ -1460,10 +1465,10 @@ resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@puppeteer/browsers@2.11.1": - version "2.11.1" - resolved "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.11.1.tgz" - integrity sha512-YmhAxs7XPuxN0j7LJloHpfD1ylhDuFmmwMvfy/+6nBSrETT2ycL53LrhgPtR+f+GcPSybQVuQ5inWWu5MrWCpA== +"@puppeteer/browsers@2.11.2": + version "2.11.2" + resolved "https://registry.yarnpkg.com/@puppeteer/browsers/-/browsers-2.11.2.tgz#54ac339579c23014535011e6dc04bf3157705d73" + integrity sha512-GBY0+2lI9fDrjgb5dFL9+enKXqyOPok9PXg/69NVkjW3bikbK9RQrNrI3qccQXmDNN7ln4j/yL89Qgvj/tfqrw== dependencies: debug "^4.4.3" extract-zip "^2.0.1" @@ -1746,13 +1751,6 @@ resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" 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": version "7946.0.16" 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" 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: version "2.0.5" 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" semver "^5.6.0" -maplibre-gl@^5.16.0: - version "5.16.0" - resolved "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.16.0.tgz" - integrity sha512-/VDY89nr4jgLJyzmhy325cG6VUI02WkZ/UfVuDbG/piXzo6ODnM+omDFIwWY8tsEsBG26DNDmNMn3Y2ikHsBiA== +maplibre-gl@^5.17.0: + version "5.17.0" + resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.17.0.tgz#b7de18caf2c70d0ba98715803eea7f1e39581c36" + integrity sha512-gwS6NpXBfWD406dtT5YfEpl2hmpMm+wcPqf04UAez/TxY1OBjiMdK2ZoMGcNIlGHelKc4+Uet6zhDdDEnlJVHA== dependencies: "@mapbox/geojson-rewind" "^0.5.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2" @@ -4590,14 +4583,13 @@ maplibre-gl@^5.16.0: "@mapbox/unitbezier" "^0.0.1" "@mapbox/vector-tile" "^2.0.4" "@mapbox/whoots-js" "^3.1.0" + "@maplibre/geojson-vt" "^5.0.4" "@maplibre/maplibre-gl-style-spec" "^24.4.1" "@maplibre/mlt" "^1.1.2" - "@maplibre/vt-pbf" "^4.2.0" + "@maplibre/vt-pbf" "^4.2.1" "@types/geojson" "^7946.0.16" - "@types/geojson-vt" "3.2.5" "@types/supercluster" "^7.1.3" earcut "^3.0.2" - geojson-vt "^4.0.2" gl-matrix "^3.4.4" kdbush "^4.0.2" murmurhash-js "^1.0.0" @@ -6019,12 +6011,12 @@ punycode@^2.1.0: resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -puppeteer-core@24.36.0: - version "24.36.0" - resolved "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.36.0.tgz" - integrity sha512-P3Ou0MAFDCQ0dK1d9F9+8jTrg6JvXjUacgG0YkJQP4kbEnUOGokSDEMmMId5ZhXD5HwsHM202E9VwEpEjWfwxg== +puppeteer-core@24.36.1: + version "24.36.1" + resolved "https://registry.yarnpkg.com/puppeteer-core/-/puppeteer-core-24.36.1.tgz#8d60c80a27b86b3d1d948155ecab2deb404cc00f" + integrity sha512-L7ykMWc3lQf3HS7ME3PSjp7wMIjJeW6+bKfH/RSTz5l6VUDGubnrC2BKj3UvM28Y5PMDFW0xniJOZHBZPpW1dQ== dependencies: - "@puppeteer/browsers" "2.11.1" + "@puppeteer/browsers" "2.11.2" chromium-bidi "13.0.1" debug "^4.4.3" devtools-protocol "0.0.1551306" @@ -6079,16 +6071,16 @@ puppeteer-extra@^3.3.6: debug "^4.1.1" deepmerge "^4.2.2" -puppeteer@^24.36.0: - version "24.36.0" - resolved "https://registry.npmjs.org/puppeteer/-/puppeteer-24.36.0.tgz" - integrity sha512-BD/VCyV/Uezvd6o7Fd1DmEJSxTzofAKplzDy6T9d4WbLTQ5A+06zY7VwO91ZlNU22vYE8sidVEsTpTrKc+EEnQ== +puppeteer@^24.36.1: + version "24.36.1" + resolved "https://registry.yarnpkg.com/puppeteer/-/puppeteer-24.36.1.tgz#55b328215090b9617eb71ca541232c14a60c14cb" + integrity sha512-uPiDUyf7gd7Il1KnqfNUtHqntL0w1LapEw5Zsuh8oCK8GsqdxySX1PzdIHKB2Dw273gWY4MW0zC5gy3Re9XlqQ== dependencies: - "@puppeteer/browsers" "2.11.1" + "@puppeteer/browsers" "2.11.2" chromium-bidi "13.0.1" cosmiconfig "^9.0.0" devtools-protocol "0.0.1551306" - puppeteer-core "24.36.0" + puppeteer-core "24.36.1" typed-query-selector "^2.12.0" 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" integrity sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A== -react-dom@19.2.3: - version "19.2.3" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz" - integrity sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg== +react-dom@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.4.tgz#6fac6bd96f7db477d966c7ec17c1a2b1ad8e6591" + integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== dependencies: scheduler "^0.27.0" @@ -6213,10 +6205,10 @@ react-window@^1.8.2: "@babel/runtime" "^7.0.0" memoize-one ">=3.1.1 <6" -react@19.2.3: - version "19.2.3" - resolved "https://registry.npmjs.org/react/-/react-19.2.3.tgz" - integrity sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA== +react@19.2.4: + version "19.2.4" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.4.tgz#438e57baa19b77cb23aab516cf635cd0579ee09a" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2"