Compare commits

...

12 Commits

Author SHA1 Message Date
orangecoding
0e29c9b9c6 next release version 2026-05-07 12:45:52 +02:00
datenwurm
f60c5859f9 feat: Add grid/table view toggle to listings overview (#305)
* feat: Add delete button to listing detail view

* feat: Add grid/table view toggle to listings overview

---------

Co-authored-by: datenwurm <git@datenwurm.net>
2026-05-07 12:12:49 +02:00
datenwurm
ee54cc495b feat: Add delete button to listing detail view (#304)
Co-authored-by: datenwurm <git@datenwurm.net>
2026-05-07 11:53:51 +02:00
orangecoding
96582ecff4 gmx example 2026-05-02 20:07:03 +02:00
orangecoding
3de82dfa41 fixing error message when passwords do not match / fixing placeholder image 2026-05-02 20:00:11 +02:00
orangecoding
d7ee4f6909 adding gitattributed§ 2026-05-01 20:12:58 +02:00
orangecoding
bf4bae9bf5 upgrading dependencies 2026-05-01 20:12:40 +02:00
orangecoding
3d10dc6042 moving from restana to fastify 2026-04-27 16:56:04 +02:00
orangecoding
fef6d06a9d next release version 2026-04-27 16:04:05 +02:00
orangecoding
951b69a67f downgrading restana 2026-04-27 16:03:45 +02:00
orangecoding
8a7b14c079 fixing toasts not showing on certain pages / adding statement about ai 🤖 2026-04-27 15:58:41 +02:00
Christian Kellner
f30ec4645c feat: Fredy UI redesign
* New design :)
2026-04-22 21:11:18 +02:00
83 changed files with 4035 additions and 2430 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
test/testFixtures/** linguist-vendored

120
CLAUDE.md Normal file
View File

@@ -0,0 +1,120 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Fredy is a self-hosted real estate finder for Germany. It scrapes German real estate portals (ImmoScout24, Immowelt, Immonet, Kleinanzeigen, WG-Gesucht, etc.), deduplicates results across providers, and sends notifications via Slack, Telegram, Email, Discord, ntfy, etc. It includes a React web UI and a built-in MCP server for LLM access to listings data.
- Node.js >= 22, ESM-only (`"type": "module"`)
- Default port: 9998, default login: admin / admin
- SQLite via `better-sqlite3` (synchronous - all DB ops are sync; only network I/O is async)
## Commands
```bash
# Development
yarn run start:backend:dev # nodemon backend
yarn run start:frontend:dev # Vite dev server (proxies /api → :9998)
# Production
yarn run start:backend # NODE_ENV=production node index.js
yarn run build:frontend # vite build → ui/public/
# Tests
yarn test # Live tests (hits actual providers)
yarn test:offline # Offline tests using HTML/JSON fixtures (fast, preferred)
yarn test:download-fixtures # Re-download fresh provider HTML fixtures
# Single test file
TEST_MODE=offline npx vitest run test/provider/immoscout.test.js
# Lint / Format
yarn lint && yarn lint:fix
yarn format && yarn format:check
# DB migrations
yarn migratedb
```
## Architecture
### Core data flow
```
index.js (startup)
├── runMigrations()
├── getProviders() # lazily imports lib/provider/*.js
├── similarityCache.init() # preloads hash cache from DB
├── api.js # starts restana HTTP server
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
└── FredyPipelineExecutioner.execute()
1. queryStringMutator(url) # inject sort-by-date param
2. provider.getListings() # API or Puppeteer+Cheerio
3. provider.normalize(listing) # raw → ParsedListing
4. provider.filter(listing) # blacklist + required fields
5. filter to hashes not yet in DB
6. provider.fetchDetails() # optional enrichment
7. geocodeAddress() # optional lat/lng
8. storeListings()
9. similarityCache.checkAndAddEntry() # cross-provider dedup
10. _filterBySpecs() + _filterByArea()
11. notify.send() # fan-out to all adapters
```
### Plugin systems
**Providers** (`lib/provider/*.js`) - each module exports:
- `metaInformation` - `{ id, name, baseUrl }`
- `config` - `ProviderConfig` with `requiredFieldNames`, `crawlContainer`, `crawlFields`, `sortByDateParam`, `normalize()`, `filter()`, optional `getListings()`, `fetchDetails()`, `activeTester()`
- `init(sourceConfig, blacklist)` - called before each job run; providers are **stateful modules** holding mutable `url` and `appliedBlackList` at module scope
**Notification adapters** (`lib/notification/adapter/*.js`) - each exports:
- `config` - `{ id, name, description, fields }` (drives the UI form)
- `send({ serviceName, newListings, notificationConfig, jobKey, baseUrl })`
- Loaded dynamically at startup via `fs.readdirSync`
### Key services
| Service | Location | Notes |
|---|---|---|
| Event bus | `lib/services/events/event-bus.js` | Plain `EventEmitter`; events: `jobs:runAll`, `jobs:runOne`, `jobs:status` |
| SSE broker | `lib/services/sse/sse-broker.js` | Per-userId `Set<ServerResponse>`; heartbeat every 25s; pushes job status to UI |
| Similarity cache | `lib/services/similarity-check/` | In-memory SHA-256 Set; refreshes hourly; cross-provider dedup by title+price+address |
| SqliteConnection | `lib/services/storage/SqliteConnection.js` | Singleton, WAL mode; `execute()`, `query()`, `withTransaction()` |
| Migrations | `lib/services/storage/migrations/` | Numbered JS files each exporting `up(db)`; checksum-tracked in `schema_migrations` |
| Extractor | `lib/services/extractor/` | Orchestrates Puppeteer + Cheerio; shared browser instance per job |
### Frontend
- React 19 SPA, Vite build → `ui/public/` (served as static by backend)
- State: Zustand single store with per-domain slices
- UI library: `@douyinfe/semi-ui`
- Map: MapLibre GL + `@mapbox/mapbox-gl-draw` + `@turf/boolean-point-in-polygon` for GeoJSON polygon filters
- In dev: Vite proxies `/api` to `:9998`
### MCP server
Two transports:
1. **stdio** (`lib/mcp/stdio.js`) - for Claude Desktop/LM Studio; opens its own DB connection (main process need not be running)
2. **HTTP** (`/api/mcp`) - authenticated via Bearer token (`mcp_token` column in `users` table)
Tools: `list_jobs`, `get_job`, `list_listings`, `get_listing`, `get_current_date_time`. Responses are Markdown via `lib/mcp/mcpNormalizer.js`.
## Key Conventions
- **ESM only** - `import`/`export` everywhere, no CommonJS
- **JSDoc typedefs** (no TypeScript) in `lib/types/` - `listing.js`, `job.js`, `filter.js`, `providerConfig.js`
- **Copyright header** required on all `.js` files - enforced by `lint-staged` pre-commit hook via `copyright.js`
- **`NoNewListingsWarning`** (`lib/errors.js`) is used as control flow to short-circuit the pipeline (not an error)
- **Test fixtures** in `test/testFixtures/` - HTML/JSON snapshots per provider; `TEST_MODE=offline` mocks `puppeteerExtractor` and global `fetch` via `test/offlineFixtures.js`
- **`conf/config.json`** is the only runtime config file; created with defaults if missing
## Coding
- After building the task, run the linter
- After building the task, run the tests
- New features must be tested
- New features must be properly documented with JsDoc
- You do **not** commit any changes, you do **not** create a new branch unless I told you so

View File

@@ -240,6 +240,20 @@ flowchart TD
F1 --> F2 F1 --> F2
``` ```
------------------------------------------------------------------------
## 🤖 Using AI such as Claude Code
When I started building Fredy, LLMs were still basically the wet dream of a few nerdy scientists.
Nowadays, its easier than ever to throw a prompt into the LLM of your choice and let 'the AI' build your stuff. Im not against that. I use Claude Code myself for smaller tasks, and I do think these tools can be really useful.
That said, I still believe humans should stay in charge. AI is great-ish at writing code, but it still lacks creativity, context, and the ability to see the full picture.
So, if you want to contribute to Fredy, using AI tools to get things done is totally fine. Just please dont stop thinking.
Ive had one too many PRs full of hallucinated bullshit.
**Thanks ;)**
------------------------------------------------------------------------ ------------------------------------------------------------------------
## 👐 Contributing ## 👐 Contributing

View File

@@ -43,13 +43,13 @@ for i in $(seq 1 30); do
done done
# Verify the DB is readable/writable via the API. # Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table if SQLite is broken this returns an error. # /api/demo is unauthenticated and reads the settings table - if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..." echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1) DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)" echo "DB is readable (got demoMode from /api/demo)"
else else
echo "DB check failed unexpected response from /api/demo: $DEMO_RESPONSE" echo "DB check failed - unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy docker logs fredy
exit 1 exit 1
fi fi

View File

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

View File

@@ -3,64 +3,100 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { notificationAdapterRouter } from './routes/notificationAdapterRouter.js'; import Fastify from 'fastify';
import { authInterceptor, cookieSession, adminInterceptor } from './security.js'; import fastifyHelmet from '@fastify/helmet';
import { generalSettingsRouter } from './routes/generalSettingsRoute.js'; import fastifyCookie from '@fastify/cookie';
import { providerRouter } from './routes/providerRouter.js'; import fastifySession from '@fastify/session';
import { versionRouter } from './routes/versionRouter.js'; import fastifyStatic from '@fastify/static';
import { loginRouter } from './routes/loginRoute.js';
import { userRouter } from './routes/userRoute.js';
import { userSettingsRouter } from './routes/userSettingsRoute.js';
import { jobRouter } from './routes/jobRouter.js';
import bodyParser from 'body-parser';
import restana from 'restana';
import files from 'serve-static';
import path from 'path'; import path from 'path';
import { getDirName } from '../utils.js'; import { getDirName } from '../utils.js';
import { demoRouter } from './routes/demoRouter.js';
import logger from '../services/logger.js';
import { listingsRouter } from './routes/listingsRouter.js';
import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js'; import { getSettings, getOrCreateSessionSecret } from '../services/storage/settingsStorage.js';
import { dashboardRouter } from './routes/dashboardRouter.js'; import logger from '../services/logger.js';
import { backupRouter } from './routes/backupRouter.js'; import { authHook, adminHook } from './security.js';
import { trackingRouter } from './routes/trackingRoute.js';
import loginPlugin from './routes/loginRoute.js';
import demoPlugin from './routes/demoRouter.js';
import jobPlugin from './routes/jobRouter.js';
import versionPlugin from './routes/versionRouter.js';
import listingsPlugin from './routes/listingsRouter.js';
import dashboardPlugin from './routes/dashboardRouter.js';
import userSettingsPlugin from './routes/userSettingsRoute.js';
import trackingPlugin from './routes/trackingRoute.js';
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
import backupPlugin from './routes/backupRouter.js';
import userPlugin from './routes/userRoute.js';
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
import providerPlugin from './routes/providerRouter.js';
import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js'; import { registerMcpRoutes } from '../mcp/mcpHttpRoute.js';
const service = restana();
const staticService = files(path.join(getDirName(), '../ui/public'));
const PORT = (await getSettings()).port || 9998; const PORT = (await getSettings()).port || 9998;
const sessionSecret = await getOrCreateSessionSecret(); const sessionSecret = await getOrCreateSessionSecret();
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000;
service.use(bodyParser.json()); const fastify = Fastify({
service.use(cookieSession(sessionSecret)); logger: false,
service.use(staticService); bodyLimit: 50 * 1024 * 1024, // 50 MB for backup uploads
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/user/settings', authInterceptor());
service.use('/api/tracking', 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/admin/backup', backupRouter);
service.use('/api/jobs/provider', providerRouter);
service.use('/api/admin/users', userRouter);
service.use('/api/user/settings', userSettingsRouter);
service.use('/api/version', versionRouter);
service.use('/api/jobs', jobRouter);
service.use('/api/login', loginRouter);
service.use('/api/listings', listingsRouter);
service.use('/api/dashboard', dashboardRouter);
service.use('/api/tracking', trackingRouter);
//this route is unsecured intentionally as it is being queried from the login page
service.use('/api/demo', demoRouter);
// MCP Streamable HTTP endpoint (secured via Bearer token, not cookie-session)
registerMcpRoutes(service);
service.start(PORT).then(() => {
logger.debug(`Started API service on port ${PORT}`);
}); });
// Security headers (CSP disabled to avoid breaking the SPA)
await fastify.register(fastifyHelmet, { contentSecurityPolicy: false });
// Cookie + session (in-memory store, signed cookie)
await fastify.register(fastifyCookie);
await fastify.register(fastifySession, {
secret: sessionSecret,
cookieName: 'fredy-admin-session',
cookie: {
maxAge: SESSION_MAX_AGE,
httpOnly: true,
secure: false,
sameSite: 'lax',
},
saveUninitialized: false,
});
// Serve the React SPA from ui/public/
await fastify.register(fastifyStatic, {
root: path.join(getDirName(), '../ui/public'),
wildcard: false,
});
// Public routes - no auth required
fastify.register(loginPlugin, { prefix: '/api/login' });
fastify.register(demoPlugin, { prefix: '/api/demo' });
// User-authenticated routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.register(jobPlugin, { prefix: '/api/jobs' });
app.register(notificationAdapterPlugin, { prefix: '/api/jobs/notificationAdapter' });
app.register(providerPlugin, { prefix: '/api/jobs/provider' });
app.register(versionPlugin, { prefix: '/api/version' });
app.register(listingsPlugin, { prefix: '/api/listings' });
app.register(dashboardPlugin, { prefix: '/api/dashboard' });
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
app.register(trackingPlugin, { prefix: '/api/tracking' });
});
// Admin-only routes
fastify.register(async (app) => {
app.addHook('preHandler', authHook);
app.addHook('preHandler', adminHook);
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
app.register(backupPlugin, { prefix: '/api/admin/backup' });
app.register(userPlugin, { prefix: '/api/admin/users' });
});
// MCP Streamable HTTP (Bearer token auth - no session)
registerMcpRoutes(fastify);
// SPA fallback - serve index.html for all non-API GET requests
fastify.setNotFoundHandler((request, reply) => {
if (!request.url.startsWith('/api/')) {
return reply.sendFile('index.html');
}
return reply.code(404).send({ error: 'Not found' });
});
await fastify.listen({ port: PORT, host: '0.0.0.0' });
logger.debug(`Started API service on port ${PORT}`);

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { import {
buildBackupFileName, buildBackupFileName,
createBackupZip, createBackupZip,
@@ -12,64 +11,41 @@ import {
} from '../../services/storage/backupRestoreService.js'; } from '../../services/storage/backupRestoreService.js';
/** /**
* Backup & Restore Admin Router * @param {import('fastify').FastifyInstance} fastify
*
* Endpoints:
* - GET /api/admin/backup
* Returns the current database as a zip download. Content-Type: application/zip
* - POST /api/admin/backup/restore?dryRun=true
* Accepts a zip file (raw body). Returns a compatibility report, does not restore.
* - POST /api/admin/backup/restore?force=true|false
* Accepts a zip file (raw body). Restores the database; when incompatible and force=false, returns 400.
*/ */
const service = restana(); export default async function backupPlugin(fastify) {
const backupRouter = service.newRouter(); // Parse raw binary uploads as Buffer
fastify.addContentTypeParser(
['application/zip', 'application/octet-stream'],
{ parseAs: 'buffer' },
(req, body, done) => done(null, body),
);
backupRouter.get('/', async (req, res) => { fastify.get('/', async (_request, reply) => {
const zipBuffer = await createBackupZip(); const zipBuffer = await createBackupZip();
const fileName = await buildBackupFileName(); const fileName = await buildBackupFileName();
res.setHeader('Content-Type', 'application/zip'); reply.header('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${fileName}"`); reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
res.send(zipBuffer); return reply.send(zipBuffer);
}); });
/** fastify.post('/restore', async (request, reply) => {
* Read the full request body as a Buffer. Used for raw zip uploads. const { dryRun = 'false', force = 'false' } = request.query || {};
* @param {import('http').IncomingMessage} req
* @returns {Promise<Buffer>}
*/
function readBody(req) {
return new Promise((resolve, reject) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
req.on('error', (e) => reject(e));
});
}
// Upload endpoint. Accepts raw zip (Content-Type: application/zip or application/octet-stream)
// Query parameters:
// - dryRun=true => only validate and return compatibility info
// - force=true => proceed even if incompatible
backupRouter.post('/restore', async (req, res) => {
const { dryRun = 'false', force = 'false' } = req.query || {};
const doDryRun = String(dryRun) === 'true'; const doDryRun = String(dryRun) === 'true';
const doForce = String(force) === 'true'; const doForce = String(force) === 'true';
const body = await readBody(req); const body = request.body; // Buffer from addContentTypeParser
if (doDryRun) { if (doDryRun) {
res.body = await precheckRestore(body); return precheckRestore(body);
return res.send();
} }
try { try {
res.body = await restoreFromZip(body, { force: doForce }); return restoreFromZip(body, { force: doForce });
return res.send();
} catch (e) { } catch (e) {
res.statusCode = 400; return reply.code(400).send({
res.body = { message: e?.message || 'Restore failed', details: e?.payload || null }; message: e?.message || 'Restore failed',
return res.send(); details: e?.payload || null,
});
} }
}); });
}
export { backupRouter };

View File

@@ -3,23 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js'; 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 { getListingsKpisForJobIds, getProviderDistributionForJobIds } from '../../services/storage/listingsStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
const service = restana(); function getAccessibleJobs(request) {
export const dashboardRouter = service.newRouter(); const currentUser = request.session.currentUser;
const admin = isAdmin(request);
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 return jobStorage
.getJobs() .getJobs()
.filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser)); .filter((job) => admin || job.userId === currentUser || job.shared_with_user.includes(currentUser));
@@ -29,17 +20,19 @@ function cap(val) {
return String(val).charAt(0).toUpperCase() + String(val).slice(1); return String(val).charAt(0).toUpperCase() + String(val).slice(1);
} }
dashboardRouter.get('/', async (req, res) => { /**
const jobs = getAccessibleJobs(req); * @param {import('fastify').FastifyInstance} fastify
*/
export default async function dashboardPlugin(fastify) {
fastify.get('/', async (request) => {
const jobs = getAccessibleJobs(request);
const settings = await getSettings(); const settings = await getSettings();
// KPIs
const totalJobs = jobs.length; const totalJobs = jobs.length;
const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0); const totalListings = jobs.reduce((sum, j) => sum + (j.numberOfFoundListings || 0), 0);
const jobIds = jobs.map((j) => j.id); const jobIds = jobs.map((j) => j.id);
const { numberOfActiveListings, medianPriceOfListings } = getListingsKpisForJobIds(jobIds); const { numberOfActiveListings, medianPriceOfListings } = 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 providerPieRaw = getProviderDistributionForJobIds(jobIds);
const providerPie = Array.isArray(providerPieRaw) const providerPie = Array.isArray(providerPieRaw)
? { ? {
@@ -53,7 +46,7 @@ dashboardRouter.get('/', async (req, res) => {
} }
: { labels: [], values: [] }; : { labels: [], values: [] };
res.body = { return {
general: { general: {
interval: settings.interval, interval: settings.interval,
lastRun: settings.lastRun || null, lastRun: settings.lastRun || null,
@@ -67,5 +60,5 @@ dashboardRouter.get('/', async (req, res) => {
}, },
pie: providerPie, pie: providerPie,
}; };
res.send();
}); });
}

View File

@@ -3,15 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const demoRouter = service.newRouter();
demoRouter.get('/', async (req, res) => { /**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function demoPlugin(fastify) {
fastify.get('/', async () => {
const settings = await getSettings(); const settings = await getSettings();
res.body = Object.assign({}, { demoMode: settings.demoMode }); return { demoMode: settings.demoMode };
res.send();
}); });
}
export { demoRouter };

View File

@@ -3,29 +3,30 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { getDirName } from '../../utils.js'; import { getDirName } from '../../utils.js';
import fs from 'fs'; import fs from 'fs';
import { ensureDemoUserExists } from '../../services/storage/userStorage.js'; import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
const service = restana(); import { isAdmin } from '../security.js';
const generalSettingsRouter = service.newRouter();
generalSettingsRouter.get('/', async (req, res) => { /**
res.body = Object.assign({}, await getSettings()); * @param {import('fastify').FastifyInstance} fastify
res.send(); */
export default async function generalSettingsPlugin(fastify) {
fastify.get('/', async () => {
return Object.assign({}, await getSettings());
}); });
generalSettingsRouter.post('/', async (req, res) => {
const { sqlitepath, ...appSettings } = req.body || {}; fastify.post('/', async (request, reply) => {
const { sqlitepath, ...appSettings } = request.body || {};
if (typeof appSettings.baseUrl === 'string') { if (typeof appSettings.baseUrl === 'string') {
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, ''); appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
} }
const localSettings = await getSettings(); const localSettings = await getSettings();
if (localSettings.demoMode) { if (localSettings.demoMode && !isAdmin(request)) {
res.send(new Error('In demo mode, it is not allowed to change these settings.')); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change these settings.' });
return;
} }
try { try {
@@ -36,9 +37,8 @@ generalSettingsRouter.post('/', async (req, res) => {
ensureDemoUserExists(); ensureDemoUserExists();
} catch (err) { } catch (err) {
logger.error(err); logger.error(err);
res.send(new Error('Error while trying to write settings.')); return reply.code(500).send({ error: 'Error while trying to write settings.' });
return;
} }
res.send(); return reply.send();
}); });
export { generalSettingsRouter }; }

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as jobStorage from '../../services/storage/jobStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import { isAdmin } from '../security.js'; import { isAdmin } from '../security.js';
@@ -13,50 +12,50 @@ import { isRunning as isJobRunning } from '../../services/jobs/run-state.js';
import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js'; import { addClient as addSseClient, removeClient } from '../../services/sse/sse-broker.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana();
const jobRouter = service.newRouter();
const DEMO_JOB_NAME = 'Demo-Job'; const DEMO_JOB_NAME = 'Demo-Job';
function doesJobBelongsToUser(job, req) { function doesJobBelongsToUser(job, request) {
const userId = req.session.currentUser; const userId = request.session.currentUser;
if (userId == null) { if (userId == null) return false;
return false;
}
const user = userStorage.getUser(userId); const user = userStorage.getUser(userId);
if (user == null) { if (user == null) return false;
return false;
}
return user.isAdmin || job.userId === user.id; return user.isAdmin || job.userId === user.id;
} }
jobRouter.get('/', async (req, res) => { /**
const isUserAdmin = isAdmin(req); * @param {import('fastify').FastifyInstance} fastify
//show only the jobs which belongs to the user (or all of the user is an admin) */
res.body = jobStorage export default async function jobPlugin(fastify) {
fastify.get('/', async (request) => {
const isUserAdmin = isAdmin(request);
return jobStorage
.getJobs() .getJobs()
.filter( .filter(
(job) => (job) =>
isUserAdmin || job.userId === req.session.currentUser || job.shared_with_user.includes(req.session.currentUser), isUserAdmin ||
job.userId === request.session.currentUser ||
job.shared_with_user.includes(request.session.currentUser),
) )
.map((job) => { .map((job) => ({
return {
...job, ...job,
running: isJobRunning(job.id), running: isJobRunning(job.id),
isOnlyShared: isOnlyShared:
!isUserAdmin && !isUserAdmin &&
job.userId !== req.session.currentUser && job.userId !== request.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser), job.shared_with_user.includes(request.session.currentUser),
}; }));
}); });
res.send(); fastify.get('/data', async (request) => {
}); const {
page,
pageSize = 50,
activityFilter,
sortfield = null,
sortdir = 'asc',
freeTextFilter,
} = request.query || {};
jobRouter.get('/data', async (req, res) => {
const { page, pageSize = 50, activityFilter, sortfield = null, sortdir = 'asc', freeTextFilter } = req.query || {};
// normalize booleans
const toBool = (v) => { const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true; if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false; if (v === false || v === 'false' || v === 0 || v === '0') return false;
@@ -71,98 +70,84 @@ jobRouter.get('/data', async (req, res) => {
activityFilter: normalizedActivity, activityFilter: normalizedActivity,
sortField: sortfield || null, sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc', sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser, userId: request.session.currentUser,
isAdmin: isAdmin(req), isAdmin: isAdmin(request),
}); });
const isUserAdmin = isAdmin(req); const isUserAdmin = isAdmin(request);
queryResult.result = queryResult.result.map((job) => ({
// Map result to include runtime status
queryResult.result = queryResult.result.map((job) => {
return {
...job, ...job,
running: isJobRunning(job.id), running: isJobRunning(job.id),
isOnlyShared: isOnlyShared:
!isUserAdmin && !isUserAdmin &&
job.userId !== req.session.currentUser && job.userId !== request.session.currentUser &&
job.shared_with_user.includes(req.session.currentUser), job.shared_with_user.includes(request.session.currentUser),
}; }));
return queryResult;
}); });
res.body = queryResult; // Server-Sent Events for real-time job status updates
res.send(); fastify.get('/events', async (request, reply) => {
}); const userId = request.session?.currentUser;
// Server-Sent Events for job status updates
jobRouter.get('/events', async (req, res) => {
const userId = req.session.currentUser;
if (userId == null) { if (userId == null) {
res.send({ message: 'Unauthorized' }, 401); return reply.code(401).send({ message: 'Unauthorized' });
return;
} }
// SSE headers
res.setHeader('Content-Type', 'text/event-stream'); reply.hijack();
res.setHeader('Cache-Control', 'no-cache'); const raw = reply.raw;
res.setHeader('Connection', 'keep-alive'); raw.setHeader('Content-Type', 'text/event-stream');
raw.setHeader('Cache-Control', 'no-cache');
raw.setHeader('Connection', 'keep-alive');
try { try {
// Initial comment to establish stream raw.write(': connected\n\n');
res.write(': connected\n\n'); addSseClient(userId, raw);
addSseClient(userId, res); const onClose = () => removeClient(userId, raw);
// Cleanup on close/aborted request.raw.on('close', onClose);
const onClose = () => removeClient(userId, res);
// restana exposes original req/res; use both close and finish
req.on('close', onClose);
req.on('aborted', onClose);
res.on('close', onClose);
} catch (e) { } catch (e) {
logger.error('Error establishing SSE connection', e); logger.error('Error establishing SSE connection', e);
try { try {
res.end(); raw.end();
} catch { } catch {
//noop /* noop */
} }
} }
}); });
jobRouter.post('/startAll', async (req, res) => { fastify.post('/startAll', async (request, reply) => {
try { try {
const userId = req.session.currentUser; const userId = request.session.currentUser;
// Emit only the userId; handler will decide based on admin/ownership
bus.emit('jobs:runAll', { userId }); bus.emit('jobs:runAll', { userId });
res.send({ message: 'Run all accepted' }, 202); return reply.code(202).send({ message: 'Run all accepted' });
} catch (err) { } catch (err) {
logger.error('Failed to trigger startAll', err); logger.error('Failed to trigger startAll', err);
res.send({ message: 'Unexpected error' }, 500); return reply.code(500).send({ message: 'Unexpected error' });
} }
}); });
// Trigger a single job run fastify.post('/:jobId/run', async (request, reply) => {
jobRouter.post('/:jobId/run', async (req, res) => { const { jobId } = request.params;
const { jobId } = req.params;
try { try {
const job = jobStorage.getJob(jobId); const job = jobStorage.getJob(jobId);
if (!job) { if (!job) {
res.send({ message: 'Job not found' }, 404); return reply.code(404).send({ message: 'Job not found' });
return;
} }
if (!doesJobBelongsToUser(job, req)) { if (!doesJobBelongsToUser(job, request)) {
res.send({ message: 'You are trying to run a job that is not associated to your user' }, 403); return reply.code(403).send({ message: 'You are trying to run a job that is not associated to your user' });
return;
} }
if (isJobRunning(jobId)) { if (isJobRunning(jobId)) {
res.send({ message: 'Job is already running' }, 409); return reply.code(409).send({ message: 'Job is already running' });
return;
} }
// fire and forget; actual execution handled by index.js listener
bus.emit('jobs:runOne', { jobId }); bus.emit('jobs:runOne', { jobId });
res.send({ message: 'Job run accepted' }, 202); return reply.code(202).send({ message: 'Job run accepted' });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
res.send({ message: 'Unexpected error triggering job' }, 500); return reply.code(500).send({ message: 'Unexpected error triggering job' });
} }
}); });
jobRouter.post('/', async (req, res) => { fastify.post('/', async (request, reply) => {
const { const {
provider, provider,
notificationAdapter, notificationAdapter,
@@ -173,23 +158,21 @@ jobRouter.post('/', async (req, res) => {
shareWithUsers = [], shareWithUsers = [],
spatialFilter = null, spatialFilter = null,
specFilter = null, specFilter = null,
} = req.body; } = request.body;
const settings = await getSettings(); const settings = await getSettings();
try { try {
let jobFromDb = jobStorage.getJob(jobId); const jobFromDb = jobStorage.getJob(jobId);
if (jobFromDb && !doesJobBelongsToUser(jobFromDb, req)) { if (jobFromDb && !doesJobBelongsToUser(jobFromDb, request)) {
res.send(new Error('You are trying to change a job that is not associated to your user.')); return reply.code(403).send({ error: 'You are trying to change a job that is not associated to your user.' });
return;
} }
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) { if (settings.demoMode && !isAdmin(request) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)')); return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
return;
} }
jobStorage.upsertJob({ jobStorage.upsertJob({
userId: req.session.currentUser, userId: request.session.currentUser,
jobId, jobId,
enabled, enabled,
name, name,
@@ -201,69 +184,62 @@ jobRouter.post('/', async (req, res) => {
specFilter, specFilter,
}); });
} catch (error) { } catch (error) {
res.send(new Error(error));
logger.error(error); logger.error(error);
return reply.code(500).send({ error: error.message });
} }
res.send(); return reply.send();
}); });
jobRouter.delete('', async (req, res) => { fastify.delete('/', async (request, reply) => {
const { jobId } = req.body; const { jobId } = request.body;
const settings = await getSettings(); const settings = await getSettings();
try { try {
const job = jobStorage.getJob(jobId); const job = jobStorage.getJob(jobId);
if (settings.demoMode && job.name === DEMO_JOB_NAME) { if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)')); return reply.code(403).send({ error: 'Sorry, but you cannot remove the Demo Job ;)' });
return;
} }
if (!doesJobBelongsToUser(job, req)) { if (!doesJobBelongsToUser(job, request)) {
res.send(new Error('You are trying to remove a job that is not associated to your user')); return reply.code(403).send({ error: 'You are trying to remove a job that is not associated to your user' });
} else { }
jobStorage.removeJob(jobId); jobStorage.removeJob(jobId);
}
} catch (error) { } catch (error) {
res.send(new Error(error));
logger.error(error); logger.error(error);
return reply.code(500).send({ error: error.message });
} }
res.send(); return reply.send();
}); });
jobRouter.put('/:jobId/status', async (req, res) => {
const { status } = req.body; fastify.put('/:jobId/status', async (request, reply) => {
const { jobId } = req.params; const { status } = request.body;
const { jobId } = request.params;
const settings = await getSettings(); const settings = await getSettings();
try { try {
const job = jobStorage.getJob(jobId); const job = jobStorage.getJob(jobId);
if (settings.demoMode && job.name === DEMO_JOB_NAME) { if (settings.demoMode && !isAdmin(request) && job.name === DEMO_JOB_NAME) {
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)')); return reply.code(403).send({ error: 'Sorry, but you cannot change the Status of our Demo Job ;)' });
return;
} }
if (!doesJobBelongsToUser(job, req)) { if (!doesJobBelongsToUser(job, request)) {
res.send(new Error('You are trying change a job that is not associated to your user')); return reply.code(403).send({ error: 'You are trying change a job that is not associated to your user' });
} else {
jobStorage.setJobStatus({
jobId,
status,
});
} }
jobStorage.setJobStatus({ jobId, status });
} catch (error) { } catch (error) {
res.send(new Error(error));
logger.error(error); logger.error(error);
return reply.code(500).send({ error: error.message });
} }
res.send(); return reply.send();
}); });
jobRouter.get('/shareableUserList', async (req, res) => { fastify.get('/shareableUserList', async (request) => {
const currentUser = req.session.currentUser; const currentUser = request.session.currentUser;
const users = userStorage.getUsers(false); const users = userStorage.getUsers(false);
res.body = users return users
.filter((user) => !user.isAdmin && user.id !== currentUser) .filter((user) => !user.isAdmin && user.id !== currentUser)
.map((user) => ({ .map((user) => ({
id: user.id, id: user.id,
name: user.username, name: user.username,
})); }));
res.send();
}); });
export { jobRouter }; }

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as listingStorage from '../../services/storage/listingsStorage.js'; import * as listingStorage from '../../services/storage/listingsStorage.js';
import * as watchListStorage from '../../services/storage/watchListStorage.js'; import * as watchListStorage from '../../services/storage/watchListStorage.js';
import { isAdmin as isAdminFn } from '../security.js'; import { isAdmin as isAdminFn } from '../security.js';
@@ -12,11 +11,11 @@ import { nullOrEmpty } from '../../utils.js';
import { getJobs } from '../../services/storage/jobStorage.js'; import { getJobs } from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana(); /**
* @param {import('fastify').FastifyInstance} fastify
const listingsRouter = service.newRouter(); */
export default async function listingsPlugin(fastify) {
listingsRouter.get('/table', async (req, res) => { fastify.get('/table', async (request) => {
const { const {
page, page,
pageSize = 50, pageSize = 50,
@@ -27,9 +26,8 @@ listingsRouter.get('/table', async (req, res) => {
sortfield = null, sortfield = null,
sortdir = 'asc', sortdir = 'asc',
freeTextFilter, freeTextFilter,
} = req.query || {}; } = request.query || {};
// normalize booleans (accept true, 'true', 1, '1' for true; false, 'false', 0, '0' for false)
const toBool = (v) => { const toBool = (v) => {
if (v === true || v === 'true' || v === 1 || v === '1') return true; if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false; if (v === false || v === 'false' || v === 0 || v === '0') return false;
@@ -47,7 +45,7 @@ listingsRouter.get('/table', async (req, res) => {
jobIdFilter = job != null ? job.id : null; jobIdFilter = job != null ? job.id : null;
} }
res.body = listingStorage.queryListings({ return listingStorage.queryListings({
page: page ? parseInt(page, 10) : 1, page: page ? parseInt(page, 10) : 1,
pageSize: pageSize ? parseInt(pageSize, 10) : 50, pageSize: pageSize ? parseInt(pageSize, 10) : 50,
freeTextFilter: freeTextFilter || null, freeTextFilter: freeTextFilter || null,
@@ -58,82 +56,69 @@ listingsRouter.get('/table', async (req, res) => {
watchListFilter: normalizedWatch, watchListFilter: normalizedWatch,
sortField: sortfield || null, sortField: sortfield || null,
sortDir: sortdir === 'desc' ? 'desc' : 'asc', sortDir: sortdir === 'desc' ? 'desc' : 'asc',
userId: req.session.currentUser, userId: request.session.currentUser,
isAdmin: isAdminFn(req), isAdmin: isAdminFn(request),
}); });
res.send();
}); });
listingsRouter.get('/map', async (req, res) => { fastify.get('/map', async (request) => {
const { jobId } = req.query || {}; const { jobId } = request.query || {};
return listingStorage.getListingsForMap({
res.body = listingStorage.getListingsForMap({
jobId: nullOrEmpty(jobId) ? null : jobId, jobId: nullOrEmpty(jobId) ? null : jobId,
userId: req.session.currentUser, userId: request.session.currentUser,
isAdmin: isAdminFn(req), isAdmin: isAdminFn(request),
}); });
res.send();
}); });
listingsRouter.get('/:listingId', async (req, res) => { fastify.get('/:listingId', async (request, reply) => {
const { listingId } = req.params; const { listingId } = request.params;
const listing = listingStorage.getListingById(listingId, req.session.currentUser, isAdminFn(req)); const listing = listingStorage.getListingById(listingId, request.session.currentUser, isAdminFn(request));
if (!listing) { if (!listing) {
res.statusCode = 404; return reply.code(404).send({ message: 'Listing not found' });
res.body = { message: 'Listing not found' };
return res.send();
} }
res.body = listing; return listing;
res.send();
}); });
// Toggle watch state for the current user on a listing fastify.post('/watch', async (request, reply) => {
listingsRouter.post('/watch', async (req, res) => {
try { try {
const { listingId } = req.body || {}; const { listingId } = request.body || {};
const userId = req.session?.currentUser; const userId = request.session?.currentUser;
if (!listingId || !userId) { if (!listingId || !userId) {
res.statusCode = 400; return reply.code(400).send({ message: 'listingId or user not provided' });
res.body = { message: 'listingId or user not provided' };
return res.send();
} }
watchListStorage.toggleWatch(listingId, userId); watchListStorage.toggleWatch(listingId, userId);
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
res.statusCode = 500; return reply.code(500).send({ message: 'Failed to toggle watch' });
res.body = { message: 'Failed to toggle watch' };
} }
res.send(); return reply.send();
}); });
listingsRouter.delete('/job', async (req, res) => { fastify.delete('/job', async (request, reply) => {
const { jobId, hardDelete = false } = req.body; const { jobId, hardDelete = false } = request.body;
const settings = await getSettings(); const settings = await getSettings();
try { try {
if (settings.demoMode) { if (settings.demoMode && !isAdminFn(request)) {
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)')); return reply.code(403).send({ error: 'Sorry, but you cannot remove listings in demo mode ;)' });
return;
} }
listingStorage.deleteListingsByJobId(jobId, hardDelete); listingStorage.deleteListingsByJobId(jobId, hardDelete);
} catch (error) { } catch (error) {
res.send(new Error(error));
logger.error(error); logger.error(error);
return reply.code(500).send({ error: error.message });
} }
res.send(); return reply.send();
}); });
listingsRouter.delete('/', async (req, res) => { fastify.delete('/', async (request, reply) => {
const { ids, hardDelete = false } = req.body; const { ids, hardDelete = false } = request.body;
try { try {
if (Array.isArray(ids) && ids.length > 0) { if (Array.isArray(ids) && ids.length > 0) {
listingStorage.deleteListingsById(ids, hardDelete); listingStorage.deleteListingsById(ids, hardDelete);
} }
} catch (error) { } catch (error) {
res.send(new Error(error));
logger.error(error); logger.error(error);
return reply.code(500).send({ error: error.message });
} }
res.send(); return reply.send();
}); });
}
export { listingsRouter };

View File

@@ -3,7 +3,6 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import * as hasher from '../../services/security/hash.js'; import * as hasher from '../../services/security/hash.js';
import { trackDemoAccessed } from '../../services/tracking/Tracker.js'; import { trackDemoAccessed } from '../../services/tracking/Tracker.js';
@@ -11,12 +10,12 @@ import logger from '../../services/logger.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const MAX_LOGIN_ATTEMPTS = 10; const MAX_LOGIN_ATTEMPTS = 10;
const LOGIN_WINDOW_MS = 15 * 60 * 1000; // 15 minutes const LOGIN_WINDOW_MS = 15 * 60 * 1000;
const loginAttempts = new Map(); // ip -> { count, firstAttempt } const loginAttempts = new Map();
function getClientIp(req) { function getClientIp(request) {
const forwarded = req.headers['x-forwarded-for']; const forwarded = request.headers['x-forwarded-for'];
return (forwarded ? forwarded.split(',')[0] : req.socket?.remoteAddress) || 'unknown'; return (forwarded ? forwarded.split(',')[0] : request.socket?.remoteAddress) || 'unknown';
} }
function isRateLimited(ip) { function isRateLimited(ip) {
@@ -30,53 +29,51 @@ function isRateLimited(ip) {
return record.count > MAX_LOGIN_ATTEMPTS; return record.count > MAX_LOGIN_ATTEMPTS;
} }
const service = restana(); /**
const loginRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
loginRouter.get('/user', async (req, res) => { */
const currentUserId = req.session.currentUser; export default async function loginPlugin(fastify) {
fastify.get('/user', async (request) => {
const currentUserId = request.session?.currentUser;
const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId); const currentUser = currentUserId == null ? null : userStorage.getUser(currentUserId);
if (currentUser == null) { if (currentUser == null) {
res.body = {}; return {};
} else { }
res.body = { return {
userId: currentUser.id, userId: currentUser.id,
isAdmin: currentUser.isAdmin, isAdmin: currentUser.isAdmin,
}; };
}
res.send();
}); });
loginRouter.post('/', async (req, res) => {
const ip = getClientIp(req); fastify.post('/', async (request, reply) => {
const ip = getClientIp(request);
if (isRateLimited(ip)) { if (isRateLimited(ip)) {
logger.error(`Login rate limit exceeded for IP ${ip}`); logger.error(`Login rate limit exceeded for IP ${ip}`);
res.send(429); return reply.code(429).send();
return;
} }
const settings = await getSettings(); const settings = await getSettings();
const { username, password } = req.body; const { username, password } = request.body;
const user = userStorage.getUsers(true).find((user) => user.username === username); const user = userStorage.getUsers(true).find((u) => u.username === username);
if (user == null) { if (user == null) {
res.send(401); return reply.code(401).send();
return;
} }
if (user.password === hasher.hash(password)) { if (user.password === hasher.hash(password)) {
if (settings.demoMode) { if (settings.demoMode) {
await trackDemoAccessed(); await trackDemoAccessed();
} }
request.session.currentUser = user.id;
req.session.currentUser = user.id; request.session.createdAt = Date.now();
req.session.createdAt = Date.now();
loginAttempts.delete(ip); loginAttempts.delete(ip);
userStorage.setLastLoginToNow({ userId: user.id }); userStorage.setLastLoginToNow({ userId: user.id });
res.send(200); return reply.code(200).send();
return;
} else { } else {
logger.error(`User ${username} tried to login, but password was wrong.`); logger.error(`User ${username} tried to login, but password was wrong.`);
} }
res.send(401); return reply.code(401).send();
}); });
loginRouter.post('/logout', async (req, res) => {
req.session = null; fastify.post('/logout', async (request, reply) => {
res.send(200); await request.session.destroy();
return reply.code(200).send();
}); });
export { loginRouter }; }

View File

@@ -4,22 +4,28 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import restana from 'restana';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
const service = restana();
const notificationAdapterRouter = service.newRouter();
const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js')); const notificationAdapterList = fs.readdirSync('./lib//notification/adapter').filter((file) => file.endsWith('.js'));
const notificationAdapter = await Promise.all( const notificationAdapter = await Promise.all(
notificationAdapterList.map(async (pro) => { notificationAdapterList.map(async (pro) => {
return await import(`../../notification/adapter/${pro}`); return await import(`../../notification/adapter/${pro}`);
}), }),
); );
notificationAdapterRouter.post('/try', async (req, res) => {
const { id, fields } = req.body; /**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function notificationAdapterPlugin(fastify) {
fastify.get('/', async () => {
return notificationAdapter.map((adapter) => adapter.config);
});
fastify.post('/try', async (request, reply) => {
const { id, fields } = request.body;
const adapter = notificationAdapter.find((adapter) => adapter.config.id === id); const adapter = notificationAdapter.find((adapter) => adapter.config.id === id);
if (adapter == null) { if (adapter == null) {
res.send(404); return reply.code(404).send();
} }
const notificationConfig = []; const notificationConfig = [];
const notificationObject = {}; const notificationObject = {};
@@ -49,17 +55,13 @@ notificationAdapterRouter.post('/try', async (req, res) => {
notificationConfig, notificationConfig,
jobKey: 'TestJob', jobKey: 'TestJob',
}); });
res.send(); return reply.send();
} catch (Exception) { } catch (Exception) {
logger.error('Error during notification adapter test:', Exception); logger.error('Error during notification adapter test:', Exception);
res.send(new Error(Exception)); return reply.code(500).send({ error: String(Exception) });
} }
}); });
notificationAdapterRouter.get('/', async (req, res) => { }
res.body = notificationAdapter.map((adapter) => adapter.config);
res.send();
});
export { notificationAdapterRouter };
const exampleDescription = ` const exampleDescription = `
Wohnungstyp: Etagenwohnung Wohnungstyp: Etagenwohnung

View File

@@ -4,17 +4,15 @@
*/ */
import fs from 'fs'; import fs from 'fs';
import restana from 'restana';
const service = restana();
const providerRouter = service.newRouter();
const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js')); const providerList = fs.readdirSync('./lib/provider').filter((file) => file.endsWith('.js'));
const provider = await Promise.all( const providers = await Promise.all(providerList.map(async (pro) => import(`../../provider/${pro}`)));
providerList.map(async (pro) => {
return await import(`../../provider/${pro}`); /**
}), * @param {import('fastify').FastifyInstance} fastify
); */
providerRouter.get('/', async (req, res) => { export default async function providerPlugin(fastify) {
res.body = provider.map((p) => p.metaInformation); fastify.get('/', async () => {
res.send(); return providers.map((p) => p.metaInformation);
}); });
export { providerRouter }; }

View File

@@ -3,35 +3,29 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import { trackPoi } from '../../services/tracking/Tracker.js'; import { trackPoi } from '../../services/tracking/Tracker.js';
import { TRACKING_POIS } from '../../TRACKING_POIS.js'; import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
const service = restana(); /**
const trackingRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
*/
trackingRouter.get('/trackingPois', async (req, res) => { export default async function trackingPlugin(fastify) {
res.body = TRACKING_POIS; fastify.get('/trackingPois', async () => {
res.send(); return TRACKING_POIS;
}); });
trackingRouter.post('/poi', async (req, res) => { fastify.post('/poi', async (request, reply) => {
const { poi } = req.body; const { poi } = request.body;
if (!poi) { if (!poi) {
res.statusCode = 400; return reply.code(400).send({ error: 'Feature name is required' });
res.send({ error: 'Feature name is required' });
return;
} }
try { try {
await trackPoi(poi); await trackPoi(poi);
res.send({ success: true }); return { success: true };
} catch (error) { } catch (error) {
logger.error('Error tracking feature', error); logger.error('Error tracking feature', error);
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message });
} }
}); });
}
export { trackingRouter };

View File

@@ -3,81 +3,73 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import * as userStorage from '../../services/storage/userStorage.js'; import * as userStorage from '../../services/storage/userStorage.js';
import * as jobStorage from '../../services/storage/jobStorage.js'; import * as jobStorage from '../../services/storage/jobStorage.js';
import { getSettings } from '../../services/storage/settingsStorage.js'; import { getSettings } from '../../services/storage/settingsStorage.js';
const service = restana(); import { isAdmin as isAdminUser } from '../security.js';
const userRouter = service.newRouter();
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) { function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0; return allUser.filter((user) => user.id !== userIdToBeRemoved && user.isAdmin).length > 0;
} }
function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, req) {
return req.session.currentUser === userIdToBeRemoved; function checkIfUserToBeRemovedIsLoggedIn(userIdToBeRemoved, request) {
return request.session.currentUser === userIdToBeRemoved;
} }
const nullOrEmpty = (str) => str == null || str.length === 0; const nullOrEmpty = (str) => str == null || str.length === 0;
userRouter.get('/', async (req, res) => { /**
res.body = userStorage.getUsers(false); * @param {import('fastify').FastifyInstance} fastify
res.send(); */
export default async function userPlugin(fastify) {
fastify.get('/', async () => {
return userStorage.getUsers(false);
}); });
userRouter.get('/:userId', async (req, res) => { fastify.get('/:userId', async (request) => {
const { userId } = req.params; const { userId } = request.params;
res.body = userStorage.getUser(userId); return userStorage.getUser(userId);
res.send();
}); });
userRouter.delete('/', async (req, res) => {
fastify.delete('/', async (request, reply) => {
const settings = await getSettings(); const settings = await getSettings();
if (settings.demoMode) { if (settings.demoMode && !isAdminUser(request)) {
res.send(new Error('In demo mode, it is not allowed to remove user.')); return reply.code(403).send({ error: 'In demo mode, it is not allowed to remove user.' });
return;
} }
const { userId } = req.body; const { userId } = request.body;
const allUser = userStorage.getUsers(false); const allUser = userStorage.getUsers(false);
if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) { if (!checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send(new Error('You are trying to remove the last admin user. This is prohibited.')); return reply.code(400).send({ error: 'You are trying to remove the last admin user. This is prohibited.' });
return;
} }
if (checkIfUserToBeRemovedIsLoggedIn(userId, req)) { if (checkIfUserToBeRemovedIsLoggedIn(userId, request)) {
res.send(new Error('You are trying to remove yourself. This is prohibited.')); return reply.code(400).send({ error: 'You are trying to remove yourself. This is prohibited.' });
return;
} }
//TODO: Remove also analytics
jobStorage.removeJobsByUserId(userId); jobStorage.removeJobsByUserId(userId);
userStorage.removeUser(userId); userStorage.removeUser(userId);
res.send(); return reply.send();
}); });
userRouter.post('/', async (req, res) => {
fastify.post('/', async (request, reply) => {
const settings = await getSettings(); const settings = await getSettings();
if (settings.demoMode) { if (settings.demoMode && !isAdminUser(request)) {
res.send(new Error('In demo mode, it is not allowed to change or add user.')); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change or add user.' });
return;
} }
const { username, password, password2, isAdmin, userId } = req.body; const { username, password, password2, isAdmin, userId } = request.body;
if (password !== password2) { if (password !== password2) {
res.send(new Error('Passwords does not match')); return reply.code(400).send({ error: 'Passwords do not match.' });
return;
} }
if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) { if (nullOrEmpty(username) || nullOrEmpty(password) || nullOrEmpty(password2)) {
res.send(new Error('Username and password are mandatory.')); return reply.code(400).send({ error: 'Username and password are mandatory.' });
return;
} }
const allUser = userStorage.getUsers(false); const allUser = userStorage.getUsers(false);
if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) { if (!isAdmin && !checkIfAnyAdminAfterRemovingUser(userId, allUser)) {
res.send( return reply.code(400).send({
new Error('You cannot change the admin flag for this user as otherwise, there is no other user in the system'), error: 'You cannot change the admin flag for this user as otherwise, there is no other user in the system',
); });
return;
} }
userStorage.upsertUser({ userStorage.upsertUser({ userId, username, password, isAdmin });
userId, return reply.send();
username,
password,
isAdmin,
}); });
res.send(); }
});
export { userRouter };

View File

@@ -3,9 +3,9 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import SqliteConnection from '../../services/storage/SqliteConnection.js'; import SqliteConnection from '../../services/storage/SqliteConnection.js';
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js'; import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
import { isAdmin } from '../security.js';
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js'; import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js'; import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js'; import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
@@ -15,40 +15,37 @@ import { TRACKING_POIS } from '../../TRACKING_POIS.js';
import logger from '../../services/logger.js'; import logger from '../../services/logger.js';
import { runGeoCordTask } from '../../services/crons/geocoding-cron.js'; import { runGeoCordTask } from '../../services/crons/geocoding-cron.js';
const service = restana(); /**
const userSettingsRouter = service.newRouter(); * @param {import('fastify').FastifyInstance} fastify
*/
userSettingsRouter.get('/', async (req, res) => { export default async function userSettingsPlugin(fastify) {
const userId = req.session.currentUser; fastify.get('/', async (request) => {
const userId = request.session.currentUser;
const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId }); const rows = SqliteConnection.query('SELECT name, value FROM settings WHERE user_id = @userId', { userId });
const settings = {}; const settings = {};
for (const r of rows) { for (const r of rows) {
settings[r.name] = fromJson(r.value, null); settings[r.name] = fromJson(r.value, null);
} }
res.body = settings; return settings;
res.send();
}); });
userSettingsRouter.get('/autocomplete', async (req, res) => { fastify.get('/autocomplete', async (request, reply) => {
const { q } = req.query; const { q } = request.query;
try { try {
const results = await autocompleteAddress(q); const results = await autocompleteAddress(q);
res.body = results; return results;
res.send();
} catch (error) { } catch (error) {
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message });
} }
}); });
userSettingsRouter.post('/home-address', async (req, res) => { fastify.post('/home-address', async (request, reply) => {
const userId = req.session.currentUser; const userId = request.session.currentUser;
const { home_address } = req.body; const { home_address } = request.body;
const settings = await getSettings(); const settings = await getSettings();
if (settings.demoMode) { if (settings.demoMode && !isAdmin(request)) {
res.send(new Error('In demo mode, it is not allowed to change the home address.')); return reply.code(403).send({ error: 'In demo mode, it is not allowed to change the home address.' });
return;
} }
try { try {
@@ -58,70 +55,75 @@ userSettingsRouter.post('/home-address', async (req, res) => {
if (coords && coords.lat !== -1) { if (coords && coords.lat !== -1) {
upsertSettings({ home_address: { address: home_address, coords } }, userId); upsertSettings({ home_address: { address: home_address, coords } }, userId);
resetGeocoordinatesAndDistanceForUser(userId); resetGeocoordinatesAndDistanceForUser(userId);
//we do NOT wait for this to finish, as we don't want to block the response
runGeoCordTask(); runGeoCordTask();
res.send({ success: true, coords }); return { success: true, coords };
} else { } else {
res.statusCode = 400; return reply.code(400).send({ error: 'Could not geocode address' });
res.send({ error: 'Could not geocode address' });
} }
} else { } else {
upsertSettings({ home_address: null }, userId); upsertSettings({ home_address: null }, userId);
res.send({ success: true }); return { success: true };
} }
} catch (error) { } catch (error) {
logger.error('Error updating home address settings', error); logger.error('Error updating home address settings', error);
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message });
} }
}); });
userSettingsRouter.post('/news-hash', async (req, res) => { fastify.post('/news-hash', async (request, reply) => {
const userId = req.session.currentUser; const userId = request.session.currentUser;
const { news_hash } = req.body; const { news_hash } = request.body;
const globalSettings = await getSettings(); const globalSettings = await getSettings();
if (globalSettings.demoMode) { if (globalSettings.demoMode && !isAdmin(request)) {
res.statusCode = 403; return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
return;
} }
try { try {
upsertSettings({ news_hash }, userId); upsertSettings({ news_hash }, userId);
res.send({ success: true }); return { success: true };
} catch (error) { } catch (error) {
logger.error('Error updating news hash', error); logger.error('Error updating news hash', error);
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message });
} }
}); });
userSettingsRouter.post('/provider-details', async (req, res) => { fastify.post('/provider-details', async (request, reply) => {
const userId = req.session.currentUser; const userId = request.session.currentUser;
const { provider_details } = req.body; const { provider_details } = request.body;
const globalSettings = await getSettings(); const globalSettings = await getSettings();
if (globalSettings.demoMode) { if (globalSettings.demoMode && !isAdmin(request)) {
res.statusCode = 403; return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
return;
} }
if (!Array.isArray(provider_details)) { if (!Array.isArray(provider_details)) {
res.statusCode = 400; return reply.code(400).send({ error: 'provider_details must be an array of provider ids.' });
res.send({ error: 'provider_details must be an array of provider ids.' });
return;
} }
try { try {
upsertSettings({ provider_details }, userId); upsertSettings({ provider_details }, userId);
res.send({ success: true }); return { success: true };
} catch (error) { } catch (error) {
logger.error('Error updating provider details setting', error); logger.error('Error updating provider details setting', error);
res.statusCode = 500; return reply.code(500).send({ error: error.message });
res.send({ error: error.message });
} }
}); });
export { userSettingsRouter }; fastify.post('/listings-view-mode', async (request, reply) => {
const userId = request.session.currentUser;
const { listings_view_mode } = request.body;
if (listings_view_mode !== 'grid' && listings_view_mode !== 'table') {
return reply.code(400).send({ error: 'listings_view_mode must be "grid" or "table".' });
}
try {
upsertSettings({ listings_view_mode }, userId);
return { success: true };
} catch (error) {
logger.error('Error updating listings view mode setting', error);
return reply.code(500).send({ error: error.message });
}
});
}

View File

@@ -3,27 +3,10 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import restana from 'restana';
import fetch from 'node-fetch'; import fetch from 'node-fetch';
import { getPackageVersion } from '../../utils.js'; import { getPackageVersion } from '../../utils.js';
import semver from 'semver'; import semver from 'semver';
const service = restana();
const versionRouter = service.newRouter();
versionRouter.get('/', async (req, res) => {
const versionPayload = await getCurrentVersionFromGithub();
const localFredyVersion = await getPackageVersion();
res.body =
versionPayload == null
? {
newVersion: false,
localFredyVersion,
}
: versionPayload;
res.send();
});
async function getCurrentVersionFromGithub() { async function getCurrentVersionFromGithub() {
const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest'); const raw = await fetch('https://api.github.com/repos/orangecoding/fredy/releases/latest');
const data = await raw.json(); const data = await raw.json();
@@ -40,4 +23,13 @@ async function getCurrentVersionFromGithub() {
}; };
} }
export { versionRouter }; /**
* @param {import('fastify').FastifyInstance} fastify
*/
export default async function versionPlugin(fastify) {
fastify.get('/', async () => {
const versionPayload = await getCurrentVersionFromGithub();
const localFredyVersion = await getPackageVersion();
return versionPayload ?? { newVersion: false, localFredyVersion };
});
}

View File

@@ -4,53 +4,50 @@
*/ */
import * as userStorage from '../services/storage/userStorage.js'; import * as userStorage from '../services/storage/userStorage.js';
import cookieSession from 'cookie-session';
const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours const SESSION_MAX_AGE = 2 * 60 * 60 * 1000; // 2 hours
const unauthorized = (res) => {
return res.send(401); /**
}; * Returns true when the request has no valid, non-expired session.
const isUnauthorized = (req) => { * @param {import('fastify').FastifyRequest} request
if (req.session.currentUser == null) return true; * @returns {boolean}
if (Date.now() - req.session.createdAt > SESSION_MAX_AGE) { */
req.session = null; export function isUnauthorized(request) {
return true; if (!request.session?.currentUser) return true;
} if (Date.now() - (request.session.createdAt || 0) > SESSION_MAX_AGE) return true;
return false; return false;
}; }
const isAdmin = (req) => {
if (!isUnauthorized(req)) { /**
const user = userStorage.getUser(req.session.currentUser); * Returns true when the session belongs to an admin user.
* @param {import('fastify').FastifyRequest} request
* @returns {boolean}
*/
export function isAdmin(request) {
if (isUnauthorized(request)) return false;
const user = userStorage.getUser(request.session.currentUser);
return user != null && user.isAdmin; return user != null && user.isAdmin;
} }
return false;
}; /**
const authInterceptor = () => { * Fastify preHandler hook - rejects unauthenticated requests with 401.
return (req, res, next) => { * @param {import('fastify').FastifyRequest} request
if (isUnauthorized(req)) { * @param {import('fastify').FastifyReply} reply
return unauthorized(res); */
} else { export async function authHook(request, reply) {
next(); if (isUnauthorized(request)) {
reply.code(401).send();
}
}
/**
* Fastify preHandler hook - rejects non-admin requests with 401.
* Apply after authHook.
* @param {import('fastify').FastifyRequest} request
* @param {import('fastify').FastifyReply} reply
*/
export async function adminHook(request, reply) {
if (!isAdmin(request)) {
reply.code(401).send();
} }
};
};
const adminInterceptor = () => {
return (req, res, next) => {
if (!isAdmin(req)) {
return unauthorized(res);
} else {
next();
} }
};
};
const cookieSession$0 = (secret) => {
return cookieSession({
name: 'fredy-admin-session',
keys: [secret],
maxAge: SESSION_MAX_AGE,
});
};
export { cookieSession$0 as cookieSession };
export { adminInterceptor };
export { authInterceptor };
export { isUnauthorized };
export { isAdmin };

View File

@@ -133,7 +133,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
#### Setup #### Setup
1. Open **Claude Desktop** 1. Open **Claude Desktop**
2. Go to **Settings → Developer → Edit Config** this opens the `claude_desktop_config.json` file 2. Go to **Settings → Developer → Edit Config** - this opens the `claude_desktop_config.json` file
3. Add the `fredy` server to the `mcpServers` object: 3. Add the `fredy` server to the `mcpServers` object:
```json ```json
@@ -158,7 +158,7 @@ The LLM will automatically call the appropriate Fredy MCP tools and present the
> - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node` > - nvm: `/Users/<you>/.nvm/versions/node/<version>/bin/node`
4. Save the file and **restart Claude Desktop** 4. Save the file and **restart Claude Desktop**
5. You should see a hammer icon (🔨) in the chat input click it to confirm the Fredy tools are listed 5. You should see a hammer icon (🔨) in the chat input - click it to confirm the Fredy tools are listed
#### Usage #### Usage
@@ -170,7 +170,7 @@ Once connected, simply ask Claude about your real estate data:
Claude will automatically call the appropriate Fredy MCP tools. Claude will automatically call the appropriate Fredy MCP tools.
> **Note:** Fredy's main web process does not need to be running the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied. > **Note:** Fredy's main web process does not need to be running - the stdio transport opens its own database connection directly. But the SQLite database file must exist and migrations must have been applied.
--- ---
@@ -252,7 +252,7 @@ Example list response:
``` ```
**Tool:** list_listings | **Status:** OK **Tool:** list_listings | **Status:** OK
Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available use page=2 to continue. Found **85** listing(s). Showing page 1 of 2 (50 on this page). More pages available - use page=2 to continue.
| ID | Title | Address | Price | Size | Provider | Active | Created | Job | | ID | Title | Address | Price | Size | Provider | Active | Created | Job |
|----|-------|---------|-------|------|----------|--------|---------|-----| |----|-------|---------|-------|------|----------|--------|---------|-----|

View File

@@ -49,7 +49,7 @@ export function createMcpServer() {
'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' + 'list_listings to search listings (supports time filters like createdAfter/createdBefore), ' +
'and get_listing for full details of a single listing. ' + 'and get_listing for full details of a single listing. ' +
'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' + 'Responses are formatted as markdown with a summary, data (tables for lists, key-value for details), and pagination info. ' +
'Always present results to the user as soon as you have them do NOT call the tool again unless you need additional pages or different data.', 'Always present results to the user as soon as you have them - do NOT call the tool again unless you need additional pages or different data.',
}, },
); );

View File

@@ -3,10 +3,6 @@
* 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) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
import { createMcpServer } from './mcpAdapter.js'; import { createMcpServer } from './mcpAdapter.js';
import { authenticateRequest } from './mcpAuthentication.js'; import { authenticateRequest } from './mcpAuthentication.js';
@@ -15,16 +11,13 @@ import crypto from 'crypto';
/** /**
* Active transports keyed by session id. * Active transports keyed by session id.
* Each session gets its own McpServer + StreamableHTTPServerTransport pair.
* @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>} * @type {Map<string, { server: McpServer, transport: StreamableHTTPServerTransport }>}
*/ */
const sessions = new Map(); const sessions = new Map();
/** /**
* Get or create a session for the given session id with authentication.
* @param {string|undefined} sessionId * @param {string|undefined} sessionId
* @param {{ userId: string }} auth * @param {{ userId: string }} auth
* @returns {{ server: McpServer, transport: StreamableHTTPServerTransport }}
*/ */
function getOrCreateSession(sessionId, auth) { function getOrCreateSession(sessionId, auth) {
if (sessionId && sessions.has(sessionId)) { if (sessionId && sessions.has(sessionId)) {
@@ -54,77 +47,67 @@ function getOrCreateSession(sessionId, auth) {
} }
/** /**
* Register MCP Streamable HTTP routes on a restana service. * Register MCP Streamable HTTP routes on a fastify instance.
* *
* Mounts handlers at /api/mcp to handle the MCP Streamable HTTP protocol: * POST /api/mcp JSON-RPC messages
* - POST /api/mcp JSON-RPC messages (initialize, tool calls, etc.) * GET /api/mcp SSE stream for server-initiated notifications
* - GET /api/mcp SSE stream for server-initiated notifications * DELETE /api/mcp session termination
* - DELETE /api/mcp session termination
* *
* All endpoints require a valid Bearer token in the Authorization header. * All endpoints require a valid Bearer token in the Authorization header.
* *
* @param {import('restana').Service} service - The restana service instance. * @param {import('fastify').FastifyInstance} fastify
*/ */
export function registerMcpRoutes(service) { export function registerMcpRoutes(fastify) {
// POST main JSON-RPC endpoint fastify.post('/api/mcp', async (request, reply) => {
service.post('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
const { server, transport } = getOrCreateSession(sessionId, auth); const { server, transport } = getOrCreateSession(sessionId, auth);
// Connect server to transport if not already connected
if (!transport.onmessage) { if (!transport.onmessage) {
await server.connect(transport); await server.connect(transport);
} }
// Inject authInfo so tools can access the authenticated user request.raw.auth = { userId: auth.userId };
req.auth = { userId: auth.userId };
await transport.handleRequest(req, res, req.body); reply.hijack();
await transport.handleRequest(request.raw, reply.raw, request.body);
}); });
// GET SSE stream for server-initiated messages fastify.get('/api/mcp', async (request, reply) => {
service.get('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) { if (!sessionId || !sessions.has(sessionId)) {
res.statusCode = 400; return reply.code(400).send({ error: 'Invalid or missing session. Send an initialize request first.' });
return res.send({ error: 'Invalid or missing session. Send an initialize request first.' });
} }
const { transport } = sessions.get(sessionId); const { transport } = sessions.get(sessionId);
await transport.handleRequest(req, res); reply.hijack();
await transport.handleRequest(request.raw, reply.raw);
}); });
// DELETE terminate session fastify.delete('/api/mcp', async (request, reply) => {
service.delete('/api/mcp', async (req, res) => { const auth = authenticateRequest(request.raw);
const auth = authenticateRequest(req);
if (!auth) { if (!auth) {
res.statusCode = 401; return reply.code(401).send({ error: 'Unauthorized. Provide a valid Bearer token.' });
return res.send({ error: 'Unauthorized. Provide a valid Bearer token.' });
} }
const sessionId = req.headers['mcp-session-id']; const sessionId = request.raw.headers['mcp-session-id'];
if (!sessionId || !sessions.has(sessionId)) { if (!sessionId || !sessions.has(sessionId)) {
res.statusCode = 404; return reply.code(404).send({ error: 'Session not found.' });
return res.send({ error: 'Session not found.' });
} }
const { transport } = sessions.get(sessionId); const { transport } = sessions.get(sessionId);
await transport.close(); await transport.close();
sessions.delete(sessionId); sessions.delete(sessionId);
res.statusCode = 200; return { ok: true };
res.send({ ok: true });
}); });
logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp'); logger.debug('MCP Streamable HTTP endpoint registered at /api/mcp');

View File

@@ -70,7 +70,7 @@ export function normalizeListJobs(queryResult, { page, pageSize }) {
let md = `**Tool:** list_jobs | **Status:** OK\n\n`; let md = `**Tool:** list_jobs | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`; md += `Found **${queryResult.totalNumber}** job(s). Showing page ${page} of ${maxPage} (${jobs.length} on this page).`;
if (hasMore) md += ` More pages available use page=${page + 1} to continue.`; if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n'; md += '\n\n';
if (jobs.length > 0) { if (jobs.length > 0) {
@@ -120,7 +120,7 @@ export function normalizeListListings(queryResult, { page, pageSize }) {
let md = `**Tool:** list_listings | **Status:** OK\n\n`; let md = `**Tool:** list_listings | **Status:** OK\n\n`;
md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`; md += `Found **${queryResult.totalNumber}** listing(s). Showing page ${page} of ${maxPage} (${listings.length} on this page).`;
if (hasMore) md += ` More pages available use page=${page + 1} to continue.`; if (hasMore) md += ` More pages available - use page=${page + 1} to continue.`;
md += '\n\n'; md += '\n\n';
if (listings.length > 0) { if (listings.length > 0) {

View File

@@ -17,6 +17,7 @@ Multiple recipients:
Common SMTP settings: Common SMTP settings:
- **Gmail** `smtp.gmail.com`, port 587, secure: false - **Gmail** - `smtp.gmail.com`, port 587, secure: false
- **Outlook** `smtp.office365.com`, port 587, secure: false - **Outlook** - `smtp.office365.com`, port 587, secure: false
- **Yahoo** `smtp.mail.yahoo.com`, port 465, secure: true - **Yahoo** - `smtp.mail.yahoo.com`, port 465, secure: true
- **Gmx** - `mail.gmx.net`, port 587, secure: true

View File

@@ -94,7 +94,7 @@ export async function applyBotPreventionToPage(page, cfg) {
// webdriver // webdriver
Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
// chrome runtime expose loadTimes, csi and app like real Chrome // chrome runtime - expose loadTimes, csi and app like real Chrome
// @ts-ignore // @ts-ignore
window.chrome = { window.chrome = {
runtime: {}, runtime: {},
@@ -129,7 +129,7 @@ export async function applyBotPreventionToPage(page, cfg) {
get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','), get: () => (window.localStorage.getItem('__LANGS__') || 'de-DE,de').split(','),
}); });
// plugins mimic real Chrome's built-in PDF plugins // plugins - mimic real Chrome's built-in PDF plugins
const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => { const makePlugin = (name, filename, description, mimeType, mimeTypeSuffix) => {
const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null }; const mimeObj = { type: mimeType, suffixes: mimeTypeSuffix, description, enabledPlugin: null };
const plugin = { name, filename, description, length: 1, 0: mimeObj }; const plugin = { name, filename, description, length: 1, 0: mimeObj };
@@ -274,14 +274,14 @@ export async function applyBotPreventionToPage(page, cfg) {
//noop //noop
} }
// document.hasFocus headless returns false; real active tabs return true // document.hasFocus - headless returns false; real active tabs return true
try { try {
document.hasFocus = () => true; document.hasFocus = () => true;
} catch { } catch {
//noop //noop
} }
// screen color depth normalise in case headless reports 0 // screen color depth - normalise in case headless reports 0
try { try {
Object.defineProperty(screen, 'colorDepth', { get: () => 24 }); Object.defineProperty(screen, 'colorDepth', { get: () => 24 });
Object.defineProperty(screen, 'pixelDepth', { get: () => 24 }); Object.defineProperty(screen, 'pixelDepth', { get: () => 24 });

View File

@@ -47,7 +47,7 @@ export async function launchBrowser(url, options) {
removeUserDataDir = true; removeUserDataDir = true;
} }
// On ARM64 Docker, Chrome for Testing has no native binary use system Chromium instead. // On ARM64 Docker, Chrome for Testing has no native binary - use system Chromium instead.
const executablePath = const executablePath =
options?.executablePath || options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined); (process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "20.4.0", "version": "21.2.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",
@@ -62,9 +62,13 @@
"Firefox ESR" "Firefox ESR"
], ],
"dependencies": { "dependencies": {
"@douyinfe/semi-icons": "^2.95.1", "@douyinfe/semi-icons": "^2.96.1",
"@douyinfe/semi-ui": "2.95.1", "@douyinfe/semi-ui": "2.96.1",
"@douyinfe/semi-ui-19": "^2.95.1", "@douyinfe/semi-ui-19": "^2.96.1",
"@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.2",
"@fastify/session": "^11.1.1",
"@fastify/static": "^9.1.3",
"@mapbox/mapbox-gl-draw": "^1.5.1", "@mapbox/mapbox-gl-draw": "^1.5.1",
"@modelcontextprotocol/sdk": "^1.29.0", "@modelcontextprotocol/sdk": "^1.29.0",
"@sendgrid/mail": "8.1.6", "@sendgrid/mail": "8.1.6",
@@ -72,55 +76,52 @@
"@vitejs/plugin-react": "6.0.1", "@vitejs/plugin-react": "6.0.1",
"adm-zip": "^0.5.17", "adm-zip": "^0.5.17",
"better-sqlite3": "^12.9.0", "better-sqlite3": "^12.9.0",
"body-parser": "2.2.2",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"cheerio": "^1.2.0", "cheerio": "^1.2.0",
"cookie-session": "2.1.1", "fastify": "^5.8.5",
"handlebars": "4.7.9", "handlebars": "4.7.9",
"maplibre-gl": "^5.23.0", "maplibre-gl": "^5.24.0",
"nanoid": "5.1.9", "nanoid": "5.1.11",
"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",
"nodemailer": "^8.0.5", "nodemailer": "^8.0.7",
"p-throttle": "^8.1.0", "p-throttle": "^8.1.0",
"package-up": "^5.0.0", "package-up": "^5.0.0",
"puppeteer": "^24.42.0", "puppeteer": "^24.43.0",
"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.5", "react": "19.2.6",
"react-chartjs-2": "^5.3.1", "react-chartjs-2": "^5.3.1",
"react-dom": "19.2.5", "react-dom": "19.2.6",
"react-range-slider-input": "^3.3.5", "react-range-slider-input": "^3.3.5",
"react-router": "7.14.1", "react-router": "7.15.0",
"react-router-dom": "7.14.1", "react-router-dom": "7.15.0",
"resend": "^6.12.2", "resend": "^6.12.3",
"restana": "5.2.0",
"semver": "^7.7.4", "semver": "^7.7.4",
"serve-static": "2.2.1",
"slack": "11.0.2", "slack": "11.0.2",
"vite": "8.0.9", "vite": "8.0.11",
"x-var": "^3.0.1", "x-var": "^3.0.1",
"zustand": "^5.0.12" "zustand": "^5.0.13"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.29.0", "@babel/core": "7.29.0",
"@babel/eslint-parser": "7.28.6", "@babel/eslint-parser": "7.28.6",
"@babel/preset-env": "7.29.2", "@babel/preset-env": "7.29.5",
"@babel/preset-react": "7.28.5", "@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "10.2.1", "eslint": "10.3.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
"globals": "^17.5.0", "globals": "^17.6.0",
"history": "5.3.0", "history": "5.3.0",
"husky": "9.1.7", "husky": "9.1.7",
"less": "4.6.4", "less": "4.6.4",
"lint-staged": "16.4.0", "lint-staged": "16.4.0",
"nodemon": "^3.1.14", "nodemon": "^3.1.14",
"prettier": "3.8.3", "prettier": "3.8.3",
"vitest": "^4.1.4" "vitest": "^4.1.5"
} }
} }

View File

@@ -78,7 +78,7 @@ describe('#immobilien.de testsuite()', () => {
expect(listing.link).toContain('https://www.immobilien.de'); expect(listing.link).toContain('https://www.immobilien.de');
expect(listing.address).toBeTypeOf('string'); expect(listing.address).toBeTypeOf('string');
expect(listing.address).not.toBe(''); expect(listing.address).not.toBe('');
// description may be null if selectors don't match yet falls back gracefully // description may be null if selectors don't match yet - falls back gracefully
if (listing.description != null) { if (listing.description != null) {
expect(listing.description).toBeTypeOf('string'); expect(listing.description).toBeTypeOf('string');
} }

View File

@@ -713,7 +713,7 @@
</div><!-- /srb-filters --> </div><!-- /srb-filters -->
<!-- Search button CMS handles via onclick="{{submit()}}"; srbDoSearch() is <!-- Search button - CMS handles via onclick="{{submit()}}"; srbDoSearch() is
a standalone fallback that skips empty params (used outside CMS context) --> a standalone fallback that skips empty params (used outside CMS context) -->
<button type="button" class="srb-search-btn" id="srbSearchBtn"> <button type="button" class="srb-search-btn" id="srbSearchBtn">
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true"> <svg width="17" height="17" viewBox="0 0 24 24" fill="none" aria-hidden="true">
@@ -740,7 +740,7 @@
</div><!-- /srb-wrap --> </div><!-- /srb-wrap -->
<!-- ═══════════════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════════════
MODAL WEITERE FILTER MODAL - WEITERE FILTER
═══════════════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════════════ -->
<!-- Screen-reader live region: announces filter changes and modal state --> <!-- Screen-reader live region: announces filter changes and modal state -->
<div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div> <div id="srbLiveRegion" role="status" aria-live="polite" aria-atomic="true" style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap"></div>
@@ -768,7 +768,7 @@
<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle> <circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="2"></circle>
</svg> </svg>
<input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false"> <input type="text" class="srb-modal-location-input" id="srbModalLocation" placeholder="Ort, Postleitzahl, Stadt" autocomplete="off" b-event="keyup" role="combobox" aria-autocomplete="list" aria-controls="srbModalLocationList" aria-expanded="false">
<!-- Mirror of the desktop grbWoHidden Piglet updates this via b-model <!-- Mirror of the desktop grbWoHidden - Piglet updates this via b-model
when select(entry) fires in the modal controller's own scope. --> when select(entry) fires in the modal controller's own scope. -->
<input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735"> <input type="hidden" id="srbModalWoHidden" value="district:2434,2695,2621,2700,2967,2734,2909,2955,2392,2746,2767,2982,2904,2612,2892,2587,2871,2975,2591,2887,2569,2640,2735">
<div b-redrawable="autocomplete" class="srb-autocomplete-wrapper"> <div b-redrawable="autocomplete" class="srb-autocomplete-wrapper">
@@ -899,7 +899,7 @@
</div> </div>
</div> </div>
<!-- ── Additional criteria CMS server-side rendered per typ+objektart ── --> <!-- ── Additional criteria - CMS server-side rendered per typ+objektart ── -->
<div class="srb-modal-criteria-groups"> <div class="srb-modal-criteria-groups">
<div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="[&quot;kaufen_grundstueck&quot;,&quot;kaufen_haus&quot;,&quot;kaufen_rendite&quot;,&quot;kaufen_wohnung&quot;,&quot;mieten_grundstueck&quot;,&quot;mieten_haus&quot;,&quot;mieten_waz&quot;,&quot;mieten_wohnung&quot;]"> <div class="srb-modal-section srb-modal-section--animated srb-modal-section--criteria" data-valid-searches="[&quot;kaufen_grundstueck&quot;,&quot;kaufen_haus&quot;,&quot;kaufen_rendite&quot;,&quot;kaufen_wohnung&quot;,&quot;mieten_grundstueck&quot;,&quot;mieten_haus&quot;,&quot;mieten_waz&quot;,&quot;mieten_wohnung&quot;]">
<div class="srb-section-body"> <div class="srb-section-body">
@@ -4393,7 +4393,7 @@
void g.offsetHeight; void g.offsetHeight;
g.classList.remove('lr-card__gallery--no-transition'); g.classList.remove('lr-card__gallery--no-transition');
// counter total (CMS doesn't evaluate expressions in attrs read from hidden span) // counter total (CMS doesn't evaluate expressions in attrs - read from hidden span)
var totalEl = g.querySelector('.lr-card__gallery-total'); var totalEl = g.querySelector('.lr-card__gallery-total');
var counter = g.querySelector('.lr-card__gallery-counter'); var counter = g.querySelector('.lr-card__gallery-counter');
if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim(); if (counter && totalEl) counter.dataset.total = totalEl.textContent.trim();

View File

@@ -1523,7 +1523,7 @@
<div class="hidden print:flex print:items-center print:justify-between mb-2"> <div class="hidden print:flex print:items-center print:justify-between mb-2">
<img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler Immobilien selbst vermarkten"> <img src="/static/img/brand/logo.b187f1e54302.svg" class="w-32" alt="ohne-makler - Immobilien selbst vermarkten">
<br> <br>
Diese Seite wurde ausgedruckt von:<br> Diese Seite wurde ausgedruckt von:<br>
https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/ https://www.ohne-makler.net/immobilien/wohnung-kaufen/nordrhein-westfalen/dusseldorf/

229
tools/devMock.js Normal file
View File

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

View File

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

View File

@@ -6,10 +6,15 @@
import { HashRouter } from 'react-router-dom'; import { HashRouter } from 'react-router-dom';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US'; import en_US from '@douyinfe/semi-ui-19/lib/es/locale/source/en_US';
import { LocaleProvider } from '@douyinfe/semi-ui-19'; import { LocaleProvider, semiGlobal } from '@douyinfe/semi-ui-19';
import App from './App'; import App from './App';
import './Index.less'; import './Index.less';
// Semi UI uses react-dom (not react-dom/client) internally for imperative renders
// like Toast, Notification, etc. In React 19, createRoot was removed from react-dom
// and lives only in react-dom/client - inject it so Toast can create its own root.
semiGlobal.config.createRoot = createRoot;
const container = document.getElementById('fredy'); const container = document.getElementById('fredy');
const root = createRoot(container); const root = createRoot(container);

View File

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

BIN
ui/src/assets/news/1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

View File

@@ -1,10 +1,11 @@
{ {
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876542", "key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876515",
"content": "content":
[ [
{ {
"title": "Open in...Fredy ;)", "title": "Table overview for listings",
"text": "With the latest version of Fredy, every notification now comes with a link that opens the listing directly inside Fredy. This is also a key step toward an upcoming...milestone :).<br/>To make this work, Fredy needs to know where it lives on the network. We try to guess the public base URL, but lets be honest, you probably know better. Take a quick look at the baseUrl in the system settings and fix it if it looks off." "text": "Thanks to https://github.com/datenwurm, we now have a table overview for listings. If you decide to use the table view, the decision will be stored.",
"media": "1.png"
} }
] ]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

BIN
ui/src/assets/no_image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,367 +3,126 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import { useState, useEffect, useMemo } from 'react'; import { Button, Tooltip } from '@douyinfe/semi-ui-19';
import {
useSearchParamState,
parseNumber,
parseString,
parseNullableBoolean,
} from '../../../hooks/useSearchParamState.js';
import {
Card,
Col,
Row,
Image,
Button,
Typography,
Pagination,
Toast,
Divider,
Input,
Select,
Empty,
Radio,
RadioGroup,
Space,
} from '@douyinfe/semi-ui-19';
import { import {
IconBriefcase, IconBriefcase,
IconCart, IconCart,
IconClock,
IconDelete, IconDelete,
IconLink, IconLink,
IconMapPin, IconMapPin,
IconStar, IconStar,
IconStarStroked, IconStarStroked,
IconSearch,
IconActivity,
IconEyeOpened, IconEyeOpened,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate, useSearchParams } from 'react-router-dom'; import no_image from '../../../assets/no_image.png';
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
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 { useActions, useSelector } from '../../../services/state/store.js';
import { debounce } from '../../../utils';
import './ListingsGrid.less'; import './ListingsGrid.less';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
const { Text } = Typography; /**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
const ListingsGrid = () => { */
const listingsData = useSelector((state) => state.listingsData); const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete }) => (
const providers = useSelector((state) => state.provider); <div className="listingsGrid__grid">
const jobs = useSelector((state) => state.jobsData.jobs); {listings.map((item) => (
const actions = useActions(); <div
const navigate = useNavigate(); key={item.id}
const sp = useSearchParams(); className="listingsGrid__card"
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
const pageSize = 40;
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
const loadData = () => {
actions.listingsData.getListingsData({
page,
pageSize,
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(
() =>
debounce((value) => {
setFreeTextFilter(value || null);
setPage(1);
}, 500),
[],
);
useEffect(() => {
return () => {
// cleanup debounced handler to avoid memory leaks
handleFilterChange.cancel && handleFilterChange.cancel();
};
}, [handleFilterChange]);
const handleWatch = async (e, item) => {
e.preventDefault();
e.stopPropagation();
try {
await xhrPost('/api/listings/watch', { listingId: item.id });
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
loadData();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
}
};
const handlePageChange = (_page) => {
setPage(_page);
};
const confirmDeletion = async (hardDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
loadData();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
}
};
const cap = (val) => {
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
};
return (
<div className="listingsGrid">
<div className="listingsGrid__topbar">
<Input
className="listingsGrid__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
defaultValue={freeTextFilter ?? ''}
onChange={handleFilterChange}
/>
<RadioGroup
type="button"
buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<Select
placeholder="Provider"
showClear
onChange={(val) => {
setProviderFilter(val);
setPage(1);
}}
value={providerFilter}
style={{ width: 130 }}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job"
showClear
onChange={(val) => {
setJobNameFilter(val);
setPage(1);
}}
value={jobNameFilter}
style={{ width: 130 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div>
{(listingsData?.result || []).length === 0 && (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available yet..."
/>
)}
<Row gutter={[16, 16]}>
{(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => navigate(`/listings/listing/${item.id}`)} role="button"
cover={ tabIndex={0}
<div style={{ position: 'relative' }}> onClick={() => onNavigate(item.id)}
<div className="listingsGrid__imageContainer"> onKeyDown={(e) => {
<Image if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsGrid__card__image-wrapper">
<img
src={item.image_url || no_image} src={item.image_url || no_image}
fallback={no_image} alt={item.title}
width="100%" onError={(e) => {
height={180} e.target.src = no_image;
style={{ objectFit: 'cover' }} }}
preview={false}
/>
<Button
icon={
item.isWatched === 1 ? (
<IconStar style={{ color: 'rgba(var(--semi-green-5), 1)' }} />
) : (
<IconStarStroked />
)
}
theme="light"
shape="circle"
size="small"
className="listingsGrid__watchButton"
onClick={(e) => handleWatch(e, item)}
/> />
{!item.is_active && (
<div className="listingsGrid__card__inactive-watermark">
<span>Inactive</span>
</div> </div>
{!item.is_active && <div className="listingsGrid__inactiveOverlay">Inactive</div>}
</div>
}
bodyStyle={{ padding: '12px' }}
>
<div className="listingsGrid__content">
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)}
</Text>
<div className="listingsGrid__price">
<IconCart size="small" />
{item.price}
</div>
<div className="listingsGrid__meta">
<Text
type="secondary"
icon={<IconMapPin />}
size="small"
ellipsis={{ showTooltip: true }}
style={{ width: '100%' }}
>
{item.address || 'No address provided'}
</Text>
<Space spacing={12} wrap>
<Text type="tertiary" size="small" icon={<IconBriefcase />}>
{item.provider.charAt(0).toUpperCase() + item.provider.slice(1)}
</Text>
<Text type="tertiary" size="small" icon={<IconClock />}>
{timeService.format(item.created_at, false)}
</Text>
</Space>
{item.distance_to_destination ? (
<Text type="tertiary" size="small" icon={<IconActivity />}>
{item.distance_to_destination} m to chosen address
</Text>
) : (
<Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated
</Text>
)} )}
<button
type="button"
className="listingsGrid__card__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
</div> </div>
<Divider margin=".6rem" />
<div className="listingsGrid__actions"> <div className="listingsGrid__card__body">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}> <div className="listingsGrid__card__title" title={item.title}>
<a href={item.link} target="_blank" rel="noopener noreferrer"> {item.title}
<IconLink />
</a>
</div> </div>
{item.price && (
<div className="listingsGrid__card__price">
<IconCart size="small" />
{item.price}
</div>
)}
{item.address && (
<div className="listingsGrid__card__meta">
<IconMapPin />
{item.address}
</div>
)}
<div className="listingsGrid__card__meta">
<IconBriefcase />
{item.provider}
</div>
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
</div>
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
<Tooltip content="Original Listing">
<Button <Button
type="secondary"
size="small"
title="View Details"
onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />}
/>
<Button
title="Remove"
type="danger"
size="small" size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setListingToDelete(item.id); window.open(item.link, '_blank');
setDeleteModalVisible(true);
}} }}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />} icon={<IconDelete />}
/> style={{ color: '#fb7185' }}
</div> theme="borderless"
</div> onClick={(e) => {
</Card> e.stopPropagation();
</Col> onDelete(item.id);
))}
</Row>
{(listingsData?.result || []).length > 0 && (
<div className="listingsGrid__pagination">
<Pagination
currentPage={page}
pageSize={pageSize}
total={listingsData?.totalNumber || 0}
onPageChange={handlePageChange}
showSizeChanger={false}
/>
</div>
)}
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}} }}
/> />
</Tooltip>
</div>
</div>
))}
</div> </div>
); );
};
export default ListingsGrid; export default ListingsGrid;

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,264 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { useState, useEffect, useMemo } from 'react';
import {
useSearchParamState,
parseNumber,
parseString,
parseNullableBoolean,
} from '../../hooks/useSearchParamState.js';
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../ListingDeletionModal.jsx';
import { xhrDelete, xhrPost } from '../../services/xhr.js';
import { useActions, useSelector } from '../../services/state/store.js';
import { debounce } from '../../utils';
import ListingsGrid from '../grid/listings/ListingsGrid.jsx';
import ListingsTable from '../table/ListingsTable.jsx';
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
import './ListingsOverview.less';
const ListingsOverview = () => {
const listingsData = useSelector((state) => state.listingsData);
const providers = useSelector((state) => state.provider);
const jobs = useSelector((state) => state.jobsData.jobs);
const userSettings = useSelector((state) => state.userSettings.settings);
const actions = useActions();
const navigate = useNavigate();
const sp = useSearchParams();
const viewMode = userSettings?.listings_view_mode ?? 'grid';
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
const pageSize = 40;
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null);
const loadData = () => {
actions.listingsData.getListingsData({
page,
pageSize,
sortfield: sortField,
sortdir: sortDir,
freeTextFilter,
filter: { watchListFilter, jobNameFilter, activityFilter, providerFilter },
});
};
useEffect(() => {
loadData();
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
const handleFilterChange = useMemo(
() =>
debounce((value) => {
setFreeTextFilter(value || null);
setPage(1);
}, 500),
[],
);
useEffect(() => {
return () => {
handleFilterChange.cancel && handleFilterChange.cancel();
};
}, [handleFilterChange]);
const handleWatch = async (e, item) => {
e.preventDefault();
e.stopPropagation();
try {
await xhrPost('/api/listings/watch', { listingId: item.id });
Toast.success(item.isWatched === 1 ? 'Listing removed from Watchlist' : 'Listing added to Watchlist');
loadData();
} catch (e) {
console.error(e);
Toast.error('Failed to operate Watchlist');
}
};
const handleDelete = (id) => {
setListingToDelete(id);
setDeleteModalVisible(true);
};
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
const confirmDeletion = async (hardDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
Toast.success('Listing successfully removed');
loadData();
} catch (error) {
Toast.error(error.message || 'Error deleting listing');
} finally {
setDeleteModalVisible(false);
setListingToDelete(null);
}
};
const listings = listingsData?.result || [];
return (
<div className="listingsOverview">
<div className="listingsOverview__topbar">
<Input
className="listingsOverview__topbar__search"
prefix={<IconSearch />}
showClear
placeholder="Search"
defaultValue={freeTextFilter ?? ''}
onChange={handleFilterChange}
/>
<RadioGroup
type="button"
buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
setPage(1);
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<Select
placeholder="Provider"
showClear
onChange={(val) => {
setProviderFilter(val);
setPage(1);
}}
value={providerFilter}
style={{ width: 130 }}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job"
showClear
onChange={(val) => {
setJobNameFilter(val);
setPage(1);
}}
value={jobNameFilter}
style={{ width: 130 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
<div className="listingsOverview__topbar__view-toggle">
<Tooltip content="Grid view">
<Button
icon={<IconGridView />}
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setListingsViewMode('grid')}
aria-label="Grid view"
aria-pressed={viewMode === 'grid'}
/>
</Tooltip>
<Tooltip content="Table view">
<Button
icon={<IconList />}
theme={viewMode === 'table' ? 'solid' : 'borderless'}
onClick={() => actions.userSettings.setListingsViewMode('table')}
aria-label="Table view"
aria-pressed={viewMode === 'table'}
/>
</Tooltip>
</div>
</div>
{listings.length === 0 && (
<Empty
image={<IllustrationNoResult />}
darkModeImage={<IllustrationNoResultDark />}
description="No listings available yet..."
/>
)}
{viewMode === 'grid' ? (
<ListingsGrid listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
) : (
<ListingsTable listings={listings} onWatch={handleWatch} onNavigate={handleNavigate} onDelete={handleDelete} />
)}
{listings.length > 0 && (
<div className="listingsOverview__pagination">
<Pagination
currentPage={page}
pageSize={pageSize}
total={listingsData?.totalNumber || 0}
onPageChange={setPage}
showSizeChanger={false}
/>
</div>
)}
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmDeletion}
onCancel={() => {
setDeleteModalVisible(false);
setListingToDelete(null);
}}
/>
</div>
);
};
export default ListingsOverview;

View File

@@ -0,0 +1,45 @@
@import '../../tokens.less';
.listingsOverview {
&__topbar {
display: flex;
align-items: center;
gap: @space-3;
margin-bottom: @space-4;
flex-wrap: wrap;
&__search {
min-width: 200px;
flex: 1;
}
&__view-toggle {
display: flex;
gap: 2px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.listingsOverview__topbar__search {
width: 100%;
flex: unset;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
}
&__pagination {
margin-top: @space-4;
display: flex;
justify-content: center;
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import { Button, Tooltip } from '@douyinfe/semi-ui-19';
import {
IconBriefcase,
IconCart,
IconDelete,
IconLink,
IconMapPin,
IconStar,
IconStarStroked,
IconEyeOpened,
} from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js';
import './ListingsTable.less';
/**
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function }} props
*/
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete }) => (
<div className="listingsTable">
{listings.map((item) => (
<div
key={item.id}
className={`listingsTable__row${!item.is_active ? ' listingsTable__row--inactive' : ''}`}
role="button"
tabIndex={0}
onClick={() => onNavigate(item.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onNavigate(item.id);
}}
>
<div className="listingsTable__row__thumb">
<img
src={item.image_url || no_image}
alt={item.title}
onError={(e) => {
e.target.src = no_image;
}}
/>
</div>
<div className="listingsTable__row__title" title={item.title}>
{item.title}
</div>
<div className="listingsTable__row__price">
{item.price ? (
<>
<IconCart size="small" />
{item.price}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__address">
{item.address ? (
<>
<IconMapPin size="small" />
{item.address}
</>
) : (
<span className="listingsTable__row__empty"></span>
)}
</div>
<div className="listingsTable__row__meta">
<IconBriefcase size="small" />
{item.provider}
</div>
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className="listingsTable__row__star"
onClick={(e) => onWatch(e, item)}
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
>
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
</button>
<Tooltip content="Original Listing">
<Button
size="small"
icon={<IconLink />}
style={{ color: '#60a5fa' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
window.open(item.link, '_blank');
}}
/>
</Tooltip>
<Tooltip content="View in Fredy">
<Button
size="small"
icon={<IconEyeOpened />}
style={{ color: '#34d399' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onNavigate(item.id);
}}
/>
</Tooltip>
<Tooltip content="Remove">
<Button
size="small"
icon={<IconDelete />}
style={{ color: '#fb7185' }}
theme="borderless"
onClick={(e) => {
e.stopPropagation();
onDelete(item.id);
}}
/>
</Tooltip>
</div>
</div>
))}
</div>
);
export default ListingsTable;

View File

@@ -0,0 +1,142 @@
@import '../../tokens.less';
.listingsTable {
display: flex;
flex-direction: column;
gap: 4px;
&__row {
display: grid;
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
align-items: center;
gap: @space-3;
padding: 8px 12px;
background: @color-elevated;
border: 1px solid @color-border;
border-radius: @radius-chip;
cursor: pointer;
transition: background @transition-fast;
&:hover {
background: #252525;
}
&--inactive {
opacity: 0.6;
}
&__thumb {
width: 56px;
height: 40px;
flex-shrink: 0;
border-radius: @radius-chip;
overflow: hidden;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
}
&__title {
font-weight: 600;
font-size: @text-sm;
color: @color-text;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__price {
font-size: @text-sm;
font-weight: 600;
color: @color-success;
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
&__address {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__meta {
font-size: @text-xs;
color: @color-muted;
display: flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__date {
font-size: @text-xs;
color: @color-faint;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 2px;
}
&__star {
width: 28px;
height: 28px;
background: transparent;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
transition: background @transition-fast;
flex-shrink: 0;
&:hover {
background: rgba(0, 0, 0, 0.1);
}
svg {
color: @color-accent;
font-size: 14px;
}
}
&__empty {
color: @color-faint;
}
@media (max-width: 900px) {
grid-template-columns: 56px 1fr 120px auto;
.listingsTable__row__address,
.listingsTable__row__meta,
.listingsTable__row__date {
display: none;
}
}
@media (max-width: 560px) {
grid-template-columns: 56px 1fr auto;
.listingsTable__row__price {
display: none;
}
}
}
}

View File

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

View File

@@ -321,6 +321,20 @@ export const useFredyState = create(
throw Exception; throw Exception;
} }
}, },
async setListingsViewMode(listings_view_mode) {
try {
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
set((state) => ({
userSettings: {
...state.userSettings,
settings: { ...state.userSettings.settings, listings_view_mode },
},
}));
} catch (Exception) {
console.error('Error while trying to update listings view mode setting. Error:', Exception);
throw Exception;
}
},
}, },
}; };

View File

@@ -13,5 +13,3 @@ export function format(ts, showSeconds = true) {
...(showSeconds ? { second: 'numeric' } : {}), ...(showSeconds ? { second: 'numeric' } : {}),
}).format(ts); }).format(ts);
} }
export const roundToHour = (ts) => Math.ceil(ts / (1000 * 60 * 60)) * (1000 * 60 * 60);

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

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

View File

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

View File

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

View File

@@ -30,6 +30,7 @@ import {
} from '../../services/backupRestoreClient'; } from '../../services/backupRestoreClient';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons'; import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import { debounce } from '../../utils'; import { debounce } from '../../utils';
import Headline from '../../components/headline/Headline.jsx';
import './GeneralSettings.less'; import './GeneralSettings.less';
function formatFromTimestamp(ts) { function formatFromTimestamp(ts) {
@@ -244,6 +245,7 @@ const GeneralSettings = function GeneralSettings() {
return ( return (
<div className="generalSettings"> <div className="generalSettings">
<Headline text="Settings" />
{!loading && ( {!loading && (
<> <>
<Tabs type="line"> <Tabs type="line">
@@ -297,7 +299,7 @@ const GeneralSettings = function GeneralSettings() {
<SegmentPart <SegmentPart
name="Analytics" name="Analytics"
helpText="Anonymous usage data to help improve Fredy provider names, adapter names, OS, Node version, and architecture." helpText="Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture."
> >
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}> <Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
Enable analytics Enable analytics

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,16 +31,19 @@ import {
IconLink, IconLink,
IconStar, IconStar,
IconStarStroked, IconStarStroked,
IconDelete,
IconExpand, IconExpand,
IconGridView, IconGridView,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl'; import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.png';
import * as timeService from '../../services/time/timeService.js'; import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js'; import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost, xhrDelete } from '../../services/xhr.js';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Headline from '../../components/headline/Headline.jsx';
import './ListingDetail.less'; import './ListingDetail.less';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -58,6 +61,7 @@ export default function ListingDetail() {
const mapContainer = useRef(null); const mapContainer = useRef(null);
const map = useRef(null); const map = useRef(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
useEffect(() => { useEffect(() => {
async function fetchListing() { async function fetchListing() {
@@ -238,6 +242,18 @@ export default function ListingDetail() {
}; };
}, [listing, loading, homeAddress]); }, [listing, loading, homeAddress]);
const confirmDeletion = async (hardDelete) => {
try {
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
Toast.success('Listing successfully removed');
navigate('/listings');
} catch (e) {
Toast.error(e.message || 'Error deleting listing');
} finally {
setDeleteModalVisible(false);
}
};
const handleWatch = async () => { const handleWatch = async () => {
try { try {
await xhrPost('/api/listings/watch', { listingId: listing.id }); await xhrPost('/api/listings/watch', { listingId: listing.id });
@@ -278,7 +294,7 @@ export default function ListingDetail() {
}, },
{ {
key: 'Provider', key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1), value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
Icon: <IconBriefcase />, Icon: <IconBriefcase />,
}, },
{ {
@@ -290,51 +306,66 @@ export default function ListingDetail() {
return ( return (
<div className="listing-detail"> <div className="listing-detail">
<div className="listing-detail__back"> <Headline
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless"> text={listing?.title || 'Listing Detail'}
actions={
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
Back Back
</Button> </Button>
</div> }
/>
<Card className="listing-detail__card"> <Card className="listing-detail__card">
<div className="listing-detail__header"> <div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center"> <Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} /> <IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text> {listing.address ? (
</Space> <a
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(listing.address)}`}
target="_blank"
rel="noopener noreferrer"
className="listing-detail__address-link"
>
{listing.address}
</a>
) : (
<Text type="secondary">No address provided</Text>
)}
</Space> </Space>
<Space wrap className="listing-detail__header-actions"> <Space wrap className="listing-detail__header-actions">
<Button <Button
icon={ icon={listing.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch} onClick={handleWatch}
theme="light" theme="borderless"
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
> >
{listing.isWatched === 1 ? 'Watched' : 'Watch'} {listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button> </Button>
<Text link={{ href: listing.link, target: '_blank' }} icon={<IconLink />} underline> <a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
<IconLink style={{ marginRight: 6 }} />
Open listing Open listing
</Text> </a>
<Button
icon={<IconDelete />}
onClick={() => setDeleteModalVisible(true)}
theme="light"
type="danger"
>
Delete
</Button>
</Space> </Space>
</div> </div>
<Row> <Row>
<Col span={24} lg={12}> <Col span={24} lg={12}>
<div className="listing-detail__image-container"> <div
className={`listing-detail__image-container${!listing.image_url ? ' listing-detail__image-container--placeholder' : ''}`}
>
<Image <Image
src={listing.image_url} src={listing.image_url ?? no_image}
fallback={no_image} fallback={<img src={no_image} alt="No image available" />}
style={{ width: '100%', height: '100%' }} style={{ width: '100%', height: '100%' }}
preview={true} preview={!!listing.image_url}
/> />
</div> </div>
</Col> </Col>
@@ -389,6 +420,12 @@ export default function ListingDetail() {
<div ref={mapContainer} className="listing-detail__map-container" /> <div ref={mapContainer} className="listing-detail__map-container" />
)} )}
</div> </div>
<ListingDeletionModal
visible={deleteModalVisible}
onConfirm={confirmDeletion}
onCancel={() => setDeleteModalVisible(false)}
/>
</div> </div>
); );
} }

View File

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

View File

@@ -3,8 +3,14 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx'; import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
import Headline from '../../components/headline/Headline.jsx';
export default function Listings() { export default function Listings() {
return <ListingsGrid />; return (
<>
<Headline text="Listings" />
<ListingsOverview />
</>
);
} }

View File

@@ -13,7 +13,7 @@ import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFro
import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19'; import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; import no_image from '../../assets/no_image.png';
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';
@@ -21,6 +21,7 @@ import { xhrDelete } from '../../services/xhr.js';
import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
import Map from '../../components/map/Map.jsx'; import Map from '../../components/map/Map.jsx';
import Headline from '../../components/headline/Headline.jsx';
const RangeSlider = _RangeSlider?.default ?? _RangeSlider; const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
@@ -354,6 +355,8 @@ export default function MapView() {
}, [listings, priceRange, homeAddress, distanceFilter]); }, [listings, priceRange, homeAddress, distanceFilter]);
return ( return (
<>
<Headline text="Map View" />
<div className="map-view-container"> <div className="map-view-container">
{!homeAddress && ( {!homeAddress && (
<Banner <Banner
@@ -476,5 +479,6 @@ export default function MapView() {
}} }}
/> />
</div> </div>
</>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,8 @@ import { useActions } from '../../../services/state/store';
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19'; import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
import './UserMutator.less'; import './UserMutator.less';
import { SegmentPart } from '../../../components/segment/SegmentPart'; import { SegmentPart } from '../../../components/segment/SegmentPart';
import { IconPlusCircle } from '@douyinfe/semi-icons'; import { IconPlusCircle, IconArrowLeft } from '@douyinfe/semi-icons';
import Headline from '../../../components/headline/Headline.jsx';
const UserMutator = function UserMutator() { const UserMutator = function UserMutator() {
const params = useParams(); const params = useParams();
@@ -58,11 +59,25 @@ const UserMutator = function UserMutator() {
navigate('/users'); navigate('/users');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
Toast.error(error.json.message); Toast.error(error.json.error);
} }
}; };
return ( return (
<>
<Headline
text={params.userId ? 'Edit User' : 'New User'}
actions={
<Button
icon={<IconArrowLeft />}
onClick={() => navigate('/users')}
theme="borderless"
style={{ color: '#909090' }}
>
Back
</Button>
}
/>
<form className="userMutator"> <form className="userMutator">
<SegmentPart name="Username" helpText="The username used to login to Fredy"> <SegmentPart name="Username" helpText="The username used to login to Fredy">
<Input <Input
@@ -103,13 +118,16 @@ const UserMutator = function UserMutator() {
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} /> <Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</SegmentPart> </SegmentPart>
<Divider margin="1rem" /> <Divider margin="1rem" />
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}> <div className="userMutator__actions">
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
Cancel Cancel
</Button> </Button>
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}> <Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
Save Save
</Button> </Button>
</div>
</form> </form>
</>
); );
}; };

View File

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

1110
yarn.lock

File diff suppressed because it is too large Load Diff