diff --git a/doc/screenshot3.png b/doc/screenshot3.png index 939d0d6..60d1772 100644 Binary files a/doc/screenshot3.png and b/doc/screenshot3.png differ diff --git a/lib/api/api.js b/lib/api/api.js index 24b28df..bec5158 100644 --- a/lib/api/api.js +++ b/lib/api/api.js @@ -6,7 +6,6 @@ import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js'; import { authInterceptor, cookieSession, adminInterceptor } from './security.js'; import { generalSettingsRouter } from './routes/generalSettingsRoute.js'; -import { analyticsRouter } from './routes/analyticsRouter.js'; import { providerRouter } from './routes/providerRouter.js'; import { versionRouter } from './routes/versionRouter.js'; import { loginRouter } from './routes/loginRoute.js'; @@ -22,6 +21,7 @@ import logger from '../services/logger.js'; import { listingsRouter } from './routes/listingsRouter.js'; import { getSettings } from '../services/storage/settingsStorage.js'; import { featureRouter } from './routes/featureRouter.js'; +import { dashboardRouter } from './routes/dashboardRouter.js'; const service = restana(); const staticService = files(path.join(getDirName(), '../ui/public')); const PORT = (await getSettings()).port || 9998; @@ -33,19 +33,21 @@ service.use('/api/admin', authInterceptor()); service.use('/api/jobs', authInterceptor()); service.use('/api/version', authInterceptor()); service.use('/api/listings', authInterceptor()); +service.use('/api/dashboard', authInterceptor()); +service.use('/api/features', authInterceptor()); // /admin can only be accessed when user is having admin permissions service.use('/api/admin', adminInterceptor()); service.use('/api/jobs/notificationAdapter', notificationAdapterRouter); service.use('/api/admin/generalSettings', generalSettingsRouter); service.use('/api/jobs/provider', providerRouter); -service.use('/api/jobs/insights', analyticsRouter); service.use('/api/admin/users', userRouter); service.use('/api/version', versionRouter); service.use('/api/jobs', jobRouter); service.use('/api/login', loginRouter); service.use('/api/listings', listingsRouter); service.use('/api/features', featureRouter); +service.use('/api/dashboard', dashboardRouter); //this route is unsecured intentionally as it is being queried from the login page service.use('/api/demo', demoRouter); diff --git a/lib/api/routes/analyticsRouter.js b/lib/api/routes/analyticsRouter.js deleted file mode 100644 index 1dfe7cc..0000000 --- a/lib/api/routes/analyticsRouter.js +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2025 by Christian Kellner. - * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause - */ - -import restana from 'restana'; -import * as listingStorage from '../../services/storage/listingsStorage.js'; -const service = restana(); -const analyticsRouter = service.newRouter(); -analyticsRouter.get('/:jobId', async (req, res) => { - const { jobId } = req.params; - res.body = listingStorage.getListingProviderDataForAnalytics(jobId) || {}; - res.send(); -}); -export { analyticsRouter }; diff --git a/lib/api/routes/dashboardRouter.js b/lib/api/routes/dashboardRouter.js new file mode 100644 index 0000000..706e204 --- /dev/null +++ b/lib/api/routes/dashboardRouter.js @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import restana from 'restana'; +import * as jobStorage from '../../services/storage/jobStorage.js'; +import * as userStorage from '../../services/storage/userStorage.js'; +import { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js'; +import { getSettings } from '../../services/storage/settingsStorage.js'; + +const service = restana(); +export const dashboardRouter = service.newRouter(); + +function isAdmin(req) { + const user = req.session?.currentUser ? userStorage.getUser(req.session.currentUser) : null; + return !!user?.isAdmin; +} + +function getAccessibleJobs(req) { + const currentUser = req.session.currentUser; + const admin = isAdmin(req); + return jobStorage + .getJobs() + .filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser)); +} + +function cap(val) { + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +} + +dashboardRouter.get('/', async (req, res) => { + const jobs = getAccessibleJobs(req); + const settings = await getSettings(); + + // KPIs + const totalJobs = jobs.length; + const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0); + const jobIds = jobs.map((j) => j.id); + const { numberOfActiveListings, avgPriceOfListings } = getListingsKpisForJobIds(jobIds); + // Build Pie data in a simple shape the frontend can consume directly + // Shape: { labels: string[], values: number[] } with values as percentages + const providerPieRaw = getProviderDistributionForJobIds(jobIds); + const providerPie = Array.isArray(providerPieRaw) + ? { + labels: providerPieRaw.map((p) => cap(p.type)), + values: providerPieRaw.map((p) => Number(p.value) || 0), + } + : providerPieRaw && typeof providerPieRaw === 'object' + ? { + labels: Array.isArray(providerPieRaw.labels) ? providerPieRaw.labels : [], + values: Array.isArray(providerPieRaw.values) ? providerPieRaw.values : [], + } + : { labels: [], values: [] }; + + res.body = { + general: { + interval: settings.interval, + lastRun: settings.lastRun || null, + nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000, + }, + kpis: { + totalJobs, + totalListings, + numberOfActiveListings, + avgPriceOfListings, + }, + pie: providerPie, + }; + res.send(); +}); diff --git a/lib/api/routes/jobRouter.js b/lib/api/routes/jobRouter.js index b05d17b..3dc6beb 100644 --- a/lib/api/routes/jobRouter.js +++ b/lib/api/routes/jobRouter.js @@ -9,7 +9,6 @@ import * as userStorage from '../../services/storage/userStorage.js'; import { isAdmin } from '../security.js'; import logger from '../../services/logger.js'; import { bus } from '../../services/events/event-bus.js'; -import { getSettings } from '../../services/storage/settingsStorage.js'; const service = restana(); const jobRouter = service.newRouter(); @@ -48,15 +47,6 @@ jobRouter.get('/', async (req, res) => { res.send(); }); -jobRouter.get('/processingTimes', async (req, res) => { - const settings = await getSettings(); - res.body = { - interval: settings.interval, - lastRun: settings.lastRun || null, - }; - res.send(); -}); - jobRouter.post('/startAll', async (req, res) => { bus.emit('jobs:runAll'); res.send(); diff --git a/lib/services/storage/listingsStorage.js b/lib/services/storage/listingsStorage.js index 703e281..1355525 100755 --- a/lib/services/storage/listingsStorage.js +++ b/lib/services/storage/listingsStorage.js @@ -7,40 +7,6 @@ import { nullOrEmpty } from '../../utils.js'; import SqliteConnection from './SqliteConnection.js'; import { nanoid } from 'nanoid'; -/** - * Build analytics data for a given job by grouping all listings by provider and - * mapping each listing hash to its creation timestamp. - * - * SQL shape: - * SELECT json_group_object(provider, json_object(hash, created_at)) AS result - * FROM listings WHERE job_id = @jobId; - * - * The resulting object has the shape: - * { - * providerA: { "": , "": , ... }, - * providerB: { ... } - * } - * - * @param {string} jobId - ID of the job whose listings should be aggregated. - * @returns {Record>} Object grouped by provider mapping listing-hash -> created_at epoch ms. - */ -export const getListingProviderDataForAnalytics = (jobId) => { - const row = SqliteConnection.query( - `SELECT COALESCE( - json_group_object(provider, json(provider_map)), - json('{}') - ) AS result - FROM (SELECT provider, - json_group_object(hash, created_at) AS provider_map - FROM listings - WHERE job_id = @jobId - GROUP BY provider);`, - { jobId }, - ); - - return row?.length > 0 ? JSON.parse(row[0].result) : {}; -}; - /** * Return a list of known listing hashes for a given job and provider. * Useful to de-duplicate before inserting new listings. @@ -59,6 +25,89 @@ export const getKnownListingHashesForJobAndProvider = (jobId, providerId) => { ).map((r) => r.hash); }; +/** + * Compute KPI aggregates for a given set of job IDs from the listings table. + * + * - numberOfActiveListings: count of listings where is_active = 1 + * - avgPriceOfListings: average of numeric price, rounded to nearest integer + * + * When no jobIds are provided, returns zeros. + * + * @param {string[]} jobIds + * @returns {{ numberOfActiveListings: number, avgPriceOfListings: number }} + */ +export const getListingsKpisForJobIds = (jobIds = []) => { + if (!Array.isArray(jobIds) || jobIds.length === 0) { + return { numberOfActiveListings: 0, avgPriceOfListings: 0 }; + } + + const placeholders = jobIds.map(() => '?').join(','); + const row = + SqliteConnection.query( + `SELECT + SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount, + AVG(price) AS avgPrice + FROM listings + WHERE job_id IN (${placeholders})`, + jobIds, + )[0] || {}; + + return { + numberOfActiveListings: Number(row.activeCount || 0), + avgPriceOfListings: row?.avgPrice == null ? 0 : Math.round(Number(row.avgPrice)), + }; +}; + +/** + * Compute distribution of listings by provider for the given set of job IDs. + * Returns data ready for the pie chart component with fields `type` and `value` (percentage). + * + * Example return: + * [ { type: 'immoscout', value: 62 }, { type: 'immowelt', value: 38 } ] + * + * When no jobIds are provided or no listings exist, returns empty array. + * + * @param {string[]} jobIds + * @returns {{ type: string, value: number }[]} + */ +export const getProviderDistributionForJobIds = (jobIds = []) => { + if (!Array.isArray(jobIds) || jobIds.length === 0) { + return []; + } + + const placeholders = jobIds.map(() => '?').join(','); + const rows = SqliteConnection.query( + `SELECT provider, COUNT(*) AS cnt + FROM listings + WHERE job_id IN (${placeholders}) + GROUP BY provider + ORDER BY cnt DESC`, + jobIds, + ); + + const total = rows.reduce((acc, r) => acc + Number(r.cnt || 0), 0); + if (total === 0) return []; + + // Map counts to integer percentage values (0-100). Ensure sum is ~100 by rounding. + const percentages = rows.map((r) => ({ + type: r.provider, + value: Math.round((Number(r.cnt) / total) * 100), + })); + + // Adjust rounding drift to keep sum at 100 (optional minor correction) + const drift = 100 - percentages.reduce((s, p) => s + p.value, 0); + if (drift !== 0 && percentages.length > 0) { + // apply drift to the largest slice to keep UX simple + let maxIdx = 0; + for (let i = 1; i < percentages.length; i++) { + if (percentages[i].value > percentages[maxIdx].value) maxIdx = i; + } + percentages[maxIdx].value = Math.max(0, percentages[maxIdx].value + drift); + } + + return percentages; +}; + /** * Return a list of listing that either are active or have an unknown status * to constantly check if they are still online diff --git a/package.json b/package.json index 61d8f68..469f511 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "16.0.1", + "version": "16.1.0", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", @@ -62,12 +62,10 @@ "@douyinfe/semi-icons": "^2.89.0", "@douyinfe/semi-ui": "2.89.0", "@sendgrid/mail": "8.1.6", - "@visactor/react-vchart": "^2.0.10", - "@visactor/vchart": "^2.0.10", - "@visactor/vchart-semi-theme": "^1.12.2", "@vitejs/plugin-react": "5.1.2", "better-sqlite3": "^12.5.0", "body-parser": "2.2.1", + "chart.js": "^4.5.1", "cheerio": "^1.1.2", "cookie-session": "2.1.1", "handlebars": "4.7.8", @@ -78,11 +76,12 @@ "node-mailjet": "6.0.11", "p-throttle": "^8.1.0", "package-up": "^5.0.0", - "puppeteer": "^24.32.1", + "puppeteer": "^24.33.0", "puppeteer-extra": "^3.3.6", "puppeteer-extra-plugin-stealth": "^2.11.2", "query-string": "9.3.1", "react": "18.3.1", + "react-chartjs-2": "^5.3.1", "react-dom": "18.3.1", "react-router": "7.10.1", "react-router-dom": "7.10.1", @@ -100,7 +99,7 @@ "@babel/preset-env": "7.28.5", "@babel/preset-react": "7.28.5", "chai": "6.2.1", - "eslint": "9.39.1", + "eslint": "9.39.2", "eslint-config-prettier": "10.1.8", "eslint-plugin-react": "7.37.5", "esmock": "2.7.3", diff --git a/ui/src/App.jsx b/ui/src/App.jsx index f7370c7..649aaf4 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -10,7 +10,6 @@ import PermissionAwareRoute from './components/permission/PermissionAwareRoute'; import GeneralSettings from './views/generalSettings/GeneralSettings'; import JobMutation from './views/jobs/mutation/JobMutation'; import UserMutator from './views/user/mutation/UserMutator'; -import JobInsight from './views/jobs/insights/JobInsight.jsx'; import { useActions, useSelector } from './services/state/store'; import { Routes, Route, Navigate } from 'react-router-dom'; import Login from './views/login/Login'; @@ -25,8 +24,8 @@ import Listings from './views/listings/Listings.jsx'; import Navigation from './components/navigation/Navigation.jsx'; import { Layout } from '@douyinfe/semi-ui'; import FredyFooter from './components/footer/FredyFooter.jsx'; -import ProcessingTimes from './views/jobs/ProcessingTimes.jsx'; import WatchlistManagement from './views/listings/management/WatchlistManagement.jsx'; +import Dashboard from './views/dashboard/Dashboard.jsx'; export default function FredyApp() { const actions = useActions(); @@ -34,7 +33,6 @@ export default function FredyApp() { const currentUser = useSelector((state) => state.user.currentUser); const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate); const settings = useSelector((state) => state.generalSettings.settings); - const processingTimes = useSelector((state) => state.jobs.processingTimes); useEffect(() => { async function init() { @@ -43,7 +41,6 @@ export default function FredyApp() { await actions.features.getFeatures(); await actions.provider.getProvider(); await actions.jobs.getJobs(); - await actions.jobs.getProcessingTimes(); await actions.jobs.getSharableUserList(); await actions.notificationAdapter.getAdapter(); await actions.generalSettings.getGeneralSettings(); @@ -88,14 +85,13 @@ export default function FredyApp() { )} {settings.analyticsEnabled === null && !settings.demoMode && } - {processingTimes != null && }
} /> } /> } /> - } /> + } /> } /> } /> } /> @@ -134,7 +130,7 @@ export default function FredyApp() { } /> - } /> + } />
diff --git a/ui/src/Index.jsx b/ui/src/Index.jsx index 7aa3466..48a502d 100644 --- a/ui/src/Index.jsx +++ b/ui/src/Index.jsx @@ -9,17 +9,12 @@ import { HashRouter } from 'react-router-dom'; import { createRoot } from 'react-dom/client'; import en_US from '@douyinfe/semi-ui/lib/es/locale/source/en_US'; import { LocaleProvider } from '@douyinfe/semi-ui'; -import { initVChartSemiTheme } from '@visactor/vchart-semi-theme'; import App from './App'; import './Index.less'; const container = document.getElementById('fredy'); const root = createRoot(container); -initVChartSemiTheme({ - defaultMode: 'dark', -}); - root.render( diff --git a/ui/src/assets/heart.png b/ui/src/assets/heart.png new file mode 100644 index 0000000..d4e838e Binary files /dev/null and b/ui/src/assets/heart.png differ diff --git a/ui/src/components/cards/ChartCard.less b/ui/src/components/cards/ChartCard.less new file mode 100644 index 0000000..a052670 --- /dev/null +++ b/ui/src/components/cards/ChartCard.less @@ -0,0 +1,23 @@ +.chartCard { + /* Use provided background with slight transparency and a brighter mix */ + background: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 20%, white 80%); + border-radius: .6rem; + border: 1px solid color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 35%, white 65%); + box-shadow: 0 6px 20px rgba(0,0,0,0.08); + /* Ensure base text has strong contrast */ + color: var(--semi-color-text-0); + + /* Semi Card header/title styling */ + .semi-card-header .semi-card-header-title { + /* Derive a tinted title color with stronger contrast towards black */ + color: color-mix(in oklab, var(--card-bg, rgb(70 72 78)) 60%, black 40%); + font-weight: 600; + } + + &__no__data { + display: grid; + place-items: center; + height: 14rem; + opacity: .7; + } +} diff --git a/ui/src/components/cards/DashboardCard.less b/ui/src/components/cards/DashboardCard.less new file mode 100644 index 0000000..f3179f7 --- /dev/null +++ b/ui/src/components/cards/DashboardCard.less @@ -0,0 +1,92 @@ +@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; + + &.blue { + .color-variant(@color-blue-bg, @color-blue-border, @color-blue-text); + } + + &.orange { + .color-variant(@color-orange-bg, @color-orange-border, @color-orange-text); + } + + &.green { + .color-variant(@color-green-bg, @color-green-border, @color-green-text); + } + + &.purple { + .color-variant(@color-purple-bg, @color-purple-border, @color-purple-text); + } + + &.gray { + .color-variant(@color-gray-bg, @color-gray-border, @color-gray-text); + } + + &__header { + display: flex; + align-items: center; + gap: .6rem; + /* Keep header from growing content height */ + min-height: 2rem; + overflow: hidden; + } + + &__icon { + border-radius: .6rem; + display: grid; + place-items: center; + } + + &__title { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__content { + margin-top: .4rem; + font-size: .7rem; + flex: 1 1 auto; + display: flex; + flex-direction: column; + justify-content: center; + overflow: hidden; + } + + &__value { + margin: 0; + font-size: 1.5rem; + line-height: 1.1; + color: #fff; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &__desc { + opacity: .8; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + +} \ No newline at end of file diff --git a/ui/src/components/cards/DashboardCardColors.less b/ui/src/components/cards/DashboardCardColors.less new file mode 100644 index 0000000..36d0707 --- /dev/null +++ b/ui/src/components/cards/DashboardCardColors.less @@ -0,0 +1,19 @@ +@color-blue-bg: rgba(0, 123, 255, 0.24); +@color-blue-border: #1E40AFFF; +@color-blue-text: #60a5fa; + +@color-orange-bg: rgba(250, 91, 5, 0.12); +@color-orange-border: #d33601; +@color-orange-text: #FB923CFF; + +@color-green-bg: rgba(38, 250, 5, 0.12); +@color-green-border: #00c316; +@color-green-text: #33f308; + +@color-purple-bg: rgba(91, 3, 218, 0.38); +@color-purple-border: #7500c3; +@color-purple-text: #b15fff; + +@color-gray-bg: rgba(110, 110, 110, 0.38); +@color-gray-border: #807f7f; +@color-gray-text: #bab9b9; diff --git a/ui/src/components/cards/KpiCard.jsx b/ui/src/components/cards/KpiCard.jsx new file mode 100644 index 0000000..d5a3d5d --- /dev/null +++ b/ui/src/components/cards/KpiCard.jsx @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 by Christian Kellner. + * 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 './DashboardCard.less'; + +export default function KpiCard({ + title, + icon, + value, + valueFontSize = '1.5rem', + description, + color = 'gray', + children, +}) { + return ( +
+
+
{icon}
+
+ {title} +
+
+
+

+ {value} + {children} +

+ {description && {description}} +
+
+ ); +} diff --git a/ui/src/components/cards/PieChartCard.jsx b/ui/src/components/cards/PieChartCard.jsx new file mode 100644 index 0000000..6d4a668 --- /dev/null +++ b/ui/src/components/cards/PieChartCard.jsx @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import React from 'react'; +import { Pie } from 'react-chartjs-2'; +import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js'; + +import './ChartCard.less'; + +ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle); + +export default function PieChartCard({ data = [] }) { + const { labels, values } = React.useMemo(() => { + if (data && typeof data === 'object' && !Array.isArray(data)) { + const lbls = Array.isArray(data.labels) ? data.labels : []; + const vals = Array.isArray(data.values) + ? data.values.map((v) => (Number.isFinite(Number(v)) ? Number(v) : 0)) + : []; + return { labels: lbls, values: vals }; + } + if (Array.isArray(data)) { + const lbls = data.map((d) => d?.type ?? 'Unknown'); + const vals = data.map((d) => { + const v = Number(d?.value); + return Number.isFinite(v) ? v : 0; + }); + return { labels: lbls, values: vals }; + } + return { labels: [], values: [] }; + }, [data]); + + const palette = React.useMemo( + () => [ + '#4e79a7', + '#f28e2b', + '#e15759', + '#76b7b2', + '#59a14f', + '#edc948', + '#b07aa1', + '#ff9da7', + '#9c755f', + '#bab0ab', + ], + [], + ); + + const chartData = React.useMemo( + () => ({ + labels, + datasets: [ + { + data: values, + backgroundColor: labels.map((_, i) => palette[i % palette.length]), + borderColor: labels.map((_, i) => palette[i % palette.length]), + borderWidth: 1, + }, + ], + }), + [labels, values, palette], + ); + + const options = React.useMemo( + () => ({ + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: true, + position: 'right', + labels: { + color: () => '#fff', + }, + }, + title: { display: false }, + tooltip: { + callbacks: { + label: (ctx) => { + const label = ctx.label || ''; + const val = ctx.parsed !== undefined ? ctx.parsed : ctx.raw; + return `${label}: ${val}%`; + }, + }, + }, + }, + }), + [], + ); + + const isEmpty = !labels || labels.length === 0 || !values || values.length === 0; + + return ( + <>{isEmpty ?
No Data
: } + ); +} diff --git a/ui/src/components/navigation/Navigate.less b/ui/src/components/navigation/Navigate.less index 394b175..d2936e4 100644 --- a/ui/src/components/navigation/Navigate.less +++ b/ui/src/components/navigation/Navigate.less @@ -1,9 +1,10 @@ .navigate { - &__logout_Button { + &__footer { align-items: center; justify-content: center; + flex-direction: column; + gap: 0.5rem; width: 100%; display: flex; - } } \ No newline at end of file diff --git a/ui/src/components/navigation/Navigation.jsx b/ui/src/components/navigation/Navigation.jsx index 74a3f55..d3da0b7 100644 --- a/ui/src/components/navigation/Navigation.jsx +++ b/ui/src/components/navigation/Navigation.jsx @@ -3,26 +3,34 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import React from 'react'; -import { Nav } from '@douyinfe/semi-ui'; -import { IconStar, IconSetting, IconTerminal } from '@douyinfe/semi-icons'; +import React, { useEffect, useState } from 'react'; +import { Button, Nav } from '@douyinfe/semi-ui'; +import { IconStar, IconSetting, IconTerminal, IconHistogram, IconSidebar } from '@douyinfe/semi-icons'; import logoWhite from '../../assets/logo_white.png'; +import heart from '../../assets/heart.png'; import Logout from '../logout/Logout.jsx'; import { useLocation, useNavigate } from 'react-router-dom'; import './Navigate.less'; -import { useScreenWidth } from '../../hooks/screenWidth.js'; import { useFeature } from '../../hooks/featureHook.js'; +import { useScreenWidth } from '../../hooks/screenWidth.js'; export default function Navigation({ isAdmin }) { const navigate = useNavigate(); const location = useLocation(); const width = useScreenWidth(); - const collapsed = width <= 850; + const [collapsed, setCollapsed] = useState(width <= 850); const watchlistFeature = useFeature('WATCHLIST_MANAGEMENT') || false; + useEffect(() => { + if (width <= 850) { + setCollapsed(true); + } + }, [width]); + const items = [ + { itemKey: '/dashboard', text: 'Dashboard', icon: }, { itemKey: '/jobs', text: 'Jobs', icon: }, { itemKey: '/listings', text: 'Listings', icon: }, ]; @@ -51,18 +59,21 @@ export default function Navigation({ isAdmin }) { return (