mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a7b14c079 | ||
|
|
f30ec4645c | ||
|
|
c78472bd19 |
120
CLAUDE.md
Normal file
120
CLAUDE.md
Normal 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
|
||||
14
README.md
14
README.md
@@ -240,6 +240,20 @@ flowchart TD
|
||||
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, it’s easier than ever to throw a prompt into the LLM of your choice and let 'the AI' build your stuff. I’m 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 don’t stop thinking.
|
||||
|
||||
I’ve had one too many PRs full of hallucinated bullshit.
|
||||
|
||||
**Thanks ;)**
|
||||
|
||||
------------------------------------------------------------------------
|
||||
|
||||
## 👐 Contributing
|
||||
|
||||
2371
docs/superpowers/plans/2026-04-22-fredy-ui-redesign.md
Normal file
2371
docs/superpowers/plans/2026-04-22-fredy-ui-redesign.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,9 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
|
||||
<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>
|
||||
<body theme-mode="dark">
|
||||
<div id="fredy" style="position: absolute; top: 0; left: 0; right: 0; bottom: 0"></div>
|
||||
|
||||
@@ -16,7 +16,7 @@ import urlModifier from './services/queryStringMutator.js';
|
||||
import logger from './services/logger.js';
|
||||
import { geocodeAddress } from './services/geocoding/geoCodingService.js';
|
||||
import { distanceMeters } from './services/listings/distanceCalculator.js';
|
||||
import { getUserSettings } from './services/storage/settingsStorage.js';
|
||||
import { getUserSettings, getSettings } from './services/storage/settingsStorage.js';
|
||||
import { updateListingDistance } from './services/storage/listingsStorage.js';
|
||||
import booleanPointInPolygon from '@turf/boolean-point-in-polygon';
|
||||
import { formatListing } from './utils/formatListing.js';
|
||||
@@ -300,17 +300,19 @@ class FredyPipelineExecutioner {
|
||||
* @returns {Promise<ParsedListing[]>} Resolves to the provided listings after notifications complete.
|
||||
* @throws {NoNewListingsWarning} When there are no listings to notify about.
|
||||
*/
|
||||
_notify(newListings) {
|
||||
async _notify(newListings) {
|
||||
if (newListings.length === 0) {
|
||||
throw new NoNewListingsWarning();
|
||||
}
|
||||
// TODO: move this to the notification adapter, so it will handle for all providers in same way.
|
||||
const formattedListings = newListings.map(formatListing);
|
||||
const settings = await getSettings();
|
||||
const baseUrl = settings?.baseUrl ?? '';
|
||||
const sendNotifications = notify.send(
|
||||
this._providerId,
|
||||
formattedListings,
|
||||
this._jobNotificationConfig,
|
||||
this._jobKey,
|
||||
baseUrl,
|
||||
);
|
||||
return Promise.all(sendNotifications).then(() => newListings);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import fs from 'fs';
|
||||
import { ensureDemoUserExists } from '../../services/storage/userStorage.js';
|
||||
import logger from '../../services/logger.js';
|
||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
const service = restana();
|
||||
const generalSettingsRouter = service.newRouter();
|
||||
|
||||
@@ -18,9 +19,12 @@ generalSettingsRouter.get('/', async (req, res) => {
|
||||
});
|
||||
generalSettingsRouter.post('/', async (req, res) => {
|
||||
const { sqlitepath, ...appSettings } = req.body || {};
|
||||
if (typeof appSettings.baseUrl === 'string') {
|
||||
appSettings.baseUrl = appSettings.baseUrl.trim().replace(/\/$/, '');
|
||||
}
|
||||
const localSettings = await getSettings();
|
||||
|
||||
if (localSettings.demoMode) {
|
||||
if (localSettings.demoMode && !isAdmin(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change these settings.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ jobRouter.post('/', async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (settings.demoMode && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||
if (settings.demoMode && !isAdmin(req) && jobFromDb && jobFromDb.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
@@ -212,7 +212,7 @@ jobRouter.delete('', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot remove the Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
@@ -235,7 +235,7 @@ jobRouter.put('/:jobId/status', async (req, res) => {
|
||||
try {
|
||||
const job = jobStorage.getJob(jobId);
|
||||
|
||||
if (settings.demoMode && job.name === DEMO_JOB_NAME) {
|
||||
if (settings.demoMode && !isAdmin(req) && job.name === DEMO_JOB_NAME) {
|
||||
res.send(new Error('Sorry, but you cannot change the Status of our Demo Job ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ listingsRouter.delete('/job', async (req, res) => {
|
||||
const { jobId, hardDelete = false } = req.body;
|
||||
const settings = await getSettings();
|
||||
try {
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdminFn(req)) {
|
||||
res.send(new Error('Sorry, but you cannot remove listings in demo mode ;)'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import restana from 'restana';
|
||||
import * as userStorage from '../../services/storage/userStorage.js';
|
||||
import * as jobStorage from '../../services/storage/jobStorage.js';
|
||||
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { isAdmin as isAdminUser } from '../security.js';
|
||||
const service = restana();
|
||||
const userRouter = service.newRouter();
|
||||
function checkIfAnyAdminAfterRemovingUser(userIdToBeRemoved, allUser) {
|
||||
@@ -29,7 +30,7 @@ userRouter.get('/:userId', async (req, res) => {
|
||||
});
|
||||
userRouter.delete('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdminUser(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to remove user.'));
|
||||
return;
|
||||
}
|
||||
@@ -51,7 +52,7 @@ userRouter.delete('/', async (req, res) => {
|
||||
});
|
||||
userRouter.post('/', async (req, res) => {
|
||||
const settings = await getSettings();
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdminUser(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change or add user.'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
import restana from 'restana';
|
||||
import SqliteConnection from '../../services/storage/SqliteConnection.js';
|
||||
import { getSettings, upsertSettings } from '../../services/storage/settingsStorage.js';
|
||||
import { isAdmin } from '../security.js';
|
||||
import { resetGeocoordinatesAndDistanceForUser } from '../../services/storage/listingsStorage.js';
|
||||
import { geocodeAddress } from '../../services/geocoding/geoCodingService.js';
|
||||
import { autocompleteAddress } from '../../services/geocoding/autocompleteService.js';
|
||||
@@ -46,7 +47,7 @@ userSettingsRouter.post('/home-address', async (req, res) => {
|
||||
const { home_address } = req.body;
|
||||
const settings = await getSettings();
|
||||
|
||||
if (settings.demoMode) {
|
||||
if (settings.demoMode && !isAdmin(req)) {
|
||||
res.send(new Error('In demo mode, it is not allowed to change the home address.'));
|
||||
return;
|
||||
}
|
||||
@@ -81,7 +82,7 @@ userSettingsRouter.post('/news-hash', async (req, res) => {
|
||||
const { news_hash } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
if (globalSettings.demoMode && !isAdmin(req)) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
@@ -102,7 +103,7 @@ userSettingsRouter.post('/provider-details', async (req, res) => {
|
||||
const { provider_details } = req.body;
|
||||
|
||||
const globalSettings = await getSettings();
|
||||
if (globalSettings.demoMode) {
|
||||
if (globalSettings.demoMode && !isAdmin(req)) {
|
||||
res.statusCode = 403;
|
||||
res.send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||
return;
|
||||
|
||||
@@ -7,13 +7,14 @@ import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { server } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
const promises = newListings.map((newListing) => {
|
||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
|
||||
return fetch(server, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -5,9 +5,18 @@
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
|
||||
export const send = ({ serviceName, newListings, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, jobKey, baseUrl }) => {
|
||||
/* eslint-disable no-console */
|
||||
return [Promise.resolve(console.info(`Found entry from service ${serviceName}, Job: ${jobKey}:`, newListings))];
|
||||
const fredyLinks = baseUrl ? newListings.map((l) => `${baseUrl}/listings/listing/${l.id}`).join(', ') : null;
|
||||
return [
|
||||
Promise.resolve(
|
||||
console.info(
|
||||
`Found entry from service ${serviceName}, Job: ${jobKey}:`,
|
||||
newListings,
|
||||
...(fredyLinks ? [`Open in Fredy: ${fredyLinks}`] : []),
|
||||
),
|
||||
),
|
||||
];
|
||||
/* eslint-enable no-console */
|
||||
};
|
||||
export const config = {
|
||||
|
||||
@@ -39,9 +39,10 @@ const generateColorFromString = (str) => {
|
||||
*
|
||||
* @param {string} jobKey - Key of job (used to set embed color)
|
||||
* @param {object} listing - Object holding listing details
|
||||
* @param baseUrl
|
||||
* @returns {object} Discord webhook embed
|
||||
*/
|
||||
const buildEmbed = (jobKey, listing) => {
|
||||
const buildEmbed = (jobKey, listing, baseUrl) => {
|
||||
const maxTitleLength = 252; // Max embed title length is 256 characters
|
||||
let title = String(listing.title ?? 'N/A');
|
||||
if (title.length > maxTitleLength) {
|
||||
@@ -79,10 +80,18 @@ const buildEmbed = (jobKey, listing) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (baseUrl && listing.id) {
|
||||
fields.push({
|
||||
name: 'Open in Fredy',
|
||||
value: `[Open in Fredy](${baseUrl}/listings/listing/${listing.id})`,
|
||||
inline: false,
|
||||
});
|
||||
}
|
||||
|
||||
return embed;
|
||||
};
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const adapter = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||
if (!webhookUrl || newListings.length === 0) return Promise.resolve([]);
|
||||
@@ -90,7 +99,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job?.name || jobKey;
|
||||
|
||||
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing));
|
||||
const embeds = newListings.map((listing) => buildEmbed(jobKey, listing, baseUrl));
|
||||
|
||||
const maxEmbedsPerMessage = 10; // Discord only allows up to 10 embeds
|
||||
const webhookPromises = [];
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
|
||||
const mapListing = (listing) => ({
|
||||
const mapListing = (listing, baseUrl) => ({
|
||||
address: listing.address,
|
||||
description: listing.description,
|
||||
id: listing.id,
|
||||
@@ -14,12 +14,13 @@ const mapListing = (listing) => ({
|
||||
size: listing.size,
|
||||
title: listing.title,
|
||||
url: listing.link,
|
||||
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { authToken, endpointUrl, selfSignedCerts } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
|
||||
const listings = newListings.map(mapListing);
|
||||
const listings = newListings.map((l) => mapListing(l, baseUrl));
|
||||
const body = {
|
||||
jobId: jobKey,
|
||||
timestamp: new Date().toISOString(),
|
||||
|
||||
@@ -35,7 +35,7 @@ const toBase64 = async (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
||||
const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
|
||||
const out = [];
|
||||
const attachments = [];
|
||||
|
||||
@@ -53,6 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
||||
jobKey,
|
||||
hasImage: false,
|
||||
imageCid: '',
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
};
|
||||
|
||||
if (imgUrl) {
|
||||
@@ -78,7 +79,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings) => {
|
||||
return { listings: out, attachments };
|
||||
};
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { apiPublicKey, apiPrivateKey, receiver, from } = notificationConfig.find(
|
||||
(adapter) => adapter.id === config.id,
|
||||
).fields;
|
||||
@@ -89,7 +90,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
||||
.map((r) => ({ Email: r.trim() }))
|
||||
.filter((r) => r.Email.length > 0);
|
||||
|
||||
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings);
|
||||
const { listings, attachments } = await mapListingsWithCid(serviceName, jobKey, newListings, baseUrl);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { webhook, channel } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
let message = `### *${jobName}* (${serviceName}) found **${newListings.length}** new listings:\n\n`;
|
||||
message += `| Title | Address | Size | Price |\n|:----|:----|:----|:----|\n`;
|
||||
message += newListings.map(
|
||||
(o) => `| [${o.title}](${o.link}) | ` + [o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') + ' |\n',
|
||||
);
|
||||
message += `| Title | Address | Size | Price |${baseUrl ? ' Open in Fredy |' : ''}\n|:----|:----|:----|:----|${baseUrl ? ':----|\n' : '\n'}`;
|
||||
message += newListings.map((o) => {
|
||||
const fredyCell = baseUrl && o.id ? ` [Open in Fredy](${baseUrl}/listings/listing/${o.id}) |` : '';
|
||||
return (
|
||||
`| [${o.title}](${o.link}) | ` +
|
||||
[o.address, o.size.replace(/2m/g, '$m^2$'), o.price].join(' | ') +
|
||||
` |${fredyCell}\n`
|
||||
);
|
||||
});
|
||||
return fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
|
||||
@@ -8,17 +8,18 @@ import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { priority, server, topic } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
|
||||
const promises = newListings.map((newListing) => {
|
||||
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||
const message = `
|
||||
Address: ${newListing.address}
|
||||
Size: ${newListing.size == null ? 'N/A' : newListing.size.replace(/2m/g, '$m^2$')}
|
||||
Price: ${newListing.price}
|
||||
Link: ${newListing.link}`;
|
||||
Link: ${newListing.link}${fredyLine}`;
|
||||
|
||||
const sanitizeHeaderValue = (value) =>
|
||||
String(value ?? '')
|
||||
|
||||
@@ -7,7 +7,7 @@ import { markdown2Html } from '../../services/markdown.js';
|
||||
import { getJob } from '../../services/storage/jobStorage.js';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { token, user, device } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
const job = getJob(jobKey);
|
||||
const jobName = job == null ? jobKey : job.name;
|
||||
@@ -15,7 +15,8 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
||||
const results = await Promise.all(
|
||||
newListings.map(async (newListing) => {
|
||||
const title = `${jobName} at ${serviceName}: ${newListing.title}`;
|
||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}`;
|
||||
const fredyLine = baseUrl && newListing.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${newListing.id}` : '';
|
||||
const message = `Address: ${newListing.address}\nSize: ${newListing.size}\nPrice: ${newListing.price}\nLink: ${newListing.link}${fredyLine}`;
|
||||
|
||||
const form = new FormData();
|
||||
form.append('token', token);
|
||||
|
||||
@@ -14,7 +14,7 @@ const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { apiKey, receiver, from } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
|
||||
const to = receiver
|
||||
@@ -41,7 +42,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
||||
|
||||
const resend = new Resend(apiKey);
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
|
||||
@@ -7,7 +7,7 @@ import sgMail from '@sendgrid/mail';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
@@ -20,12 +20,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
||||
hasImage: Boolean(image),
|
||||
// optional plain text snippet
|
||||
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { apiKey, receiver, from, templateId } = notificationConfig.find((adapter) => adapter.id === config.id).fields;
|
||||
|
||||
sgMail.setApiKey(apiKey);
|
||||
@@ -36,7 +37,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
.map((r) => r.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||
|
||||
const msg = {
|
||||
templateId,
|
||||
|
||||
@@ -7,7 +7,7 @@ import Slack from 'slack';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const buildBlocks = (serviceName, jobKey, p) => {
|
||||
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (baseUrl && p.id) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'context',
|
||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||
@@ -44,7 +51,7 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
||||
return blocks;
|
||||
};
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { token, channel } = notificationConfig.find((a) => a.id === config.id).fields;
|
||||
|
||||
return Promise.allSettled(
|
||||
@@ -53,7 +60,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
token,
|
||||
channel,
|
||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||
blocks: buildBlocks(serviceName, jobKey, p),
|
||||
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||
unfurl_links: false,
|
||||
unfurl_media: false,
|
||||
}),
|
||||
|
||||
@@ -7,7 +7,7 @@ import fetch from 'node-fetch';
|
||||
import { markdown2Html } from '../../services/markdown.js';
|
||||
import { normalizeImageUrl } from '../../utils.js';
|
||||
|
||||
const buildBlocks = (serviceName, jobKey, p) => {
|
||||
const buildBlocks = (serviceName, jobKey, p, baseUrl) => {
|
||||
const blocks = [
|
||||
{
|
||||
type: 'header',
|
||||
@@ -36,6 +36,13 @@ const buildBlocks = (serviceName, jobKey, p) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (baseUrl && p.id) {
|
||||
blocks.push({
|
||||
type: 'section',
|
||||
text: { type: 'mrkdwn', text: `<${baseUrl}/listings/listing/${p.id}|Open in Fredy>` },
|
||||
});
|
||||
}
|
||||
|
||||
blocks.push({
|
||||
type: 'context',
|
||||
elements: [{ type: 'mrkdwn', text: 'Powered by Fredy' }],
|
||||
@@ -51,7 +58,7 @@ const postJson = (url, body) =>
|
||||
body,
|
||||
});
|
||||
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const adapter = notificationConfig.find((a) => a.id === config.id);
|
||||
const webhookUrl = adapter?.fields?.webhookUrl;
|
||||
if (!webhookUrl) return Promise.resolve([]);
|
||||
@@ -59,7 +66,7 @@ export const send = ({ serviceName, newListings, notificationConfig, jobKey }) =
|
||||
const promises = newListings.map((p) => {
|
||||
const body = JSON.stringify({
|
||||
text: `${serviceName} ${jobKey}: ${p.title}`,
|
||||
blocks: buildBlocks(serviceName, jobKey, p),
|
||||
blocks: buildBlocks(serviceName, jobKey, p, baseUrl),
|
||||
unfurl_links: false,
|
||||
unfurl_media: false,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ const __dirname = getDirName();
|
||||
const template = fs.readFileSync(path.resolve(__dirname + '/notification/emailTemplate/template.hbs'), 'utf8');
|
||||
const emailTemplate = Handlebars.compile(template);
|
||||
|
||||
const mapListings = (serviceName, jobKey, listings) =>
|
||||
const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
||||
listings.map((l) => {
|
||||
const image = normalizeImageUrl(l.image);
|
||||
return {
|
||||
@@ -25,12 +25,13 @@ const mapListings = (serviceName, jobKey, listings) =>
|
||||
price: l.price || '',
|
||||
image,
|
||||
hasImage: Boolean(image),
|
||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
||||
serviceName,
|
||||
jobKey,
|
||||
};
|
||||
});
|
||||
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey }) => {
|
||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||
const { host, port, secure, username, password, receiver, from } = notificationConfig.find(
|
||||
(adapter) => adapter.id === config.id,
|
||||
).fields;
|
||||
@@ -51,7 +52,7 @@ export const send = async ({ serviceName, newListings, notificationConfig, jobKe
|
||||
},
|
||||
});
|
||||
|
||||
const listings = mapListings(serviceName, jobKey, newListings);
|
||||
const listings = mapListings(serviceName, jobKey, newListings, baseUrl);
|
||||
|
||||
const html = emailTemplate({
|
||||
serviceName: `Job: (${jobKey}) | Service: ${serviceName}`,
|
||||
|
||||
@@ -80,12 +80,14 @@ function escapeHtml(s = '') {
|
||||
* @param {string} [o.link]
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildCaption(jobName, serviceName, o) {
|
||||
function buildCaption(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
const fredyLink =
|
||||
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
|
||||
return `<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n<a href='${escapeHtml(
|
||||
o.link || '',
|
||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}`.slice(0, 1024);
|
||||
)}'><b>${escapeHtml(title)}</b></a>\n${escapeHtml(meta)}${fredyLink}`.slice(0, 1024);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,13 +97,15 @@ function buildCaption(jobName, serviceName, o) {
|
||||
* @param {Object} o - Listing object
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildText(jobName, serviceName, o) {
|
||||
function buildText(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
const fredyLink =
|
||||
baseUrl && o.id ? `\n<a href='${escapeHtml(`${baseUrl}/listings/listing/${o.id}`)}'>Open in Fredy</a>` : '';
|
||||
return (
|
||||
`<i>${escapeHtml(jobName)}</i> (${escapeHtml(serviceName)})\n` +
|
||||
`<a href='${escapeHtml(o.link || '')}'><b>${escapeHtml(title)}</b></a>\n` +
|
||||
`${escapeHtml(meta)}`
|
||||
`${escapeHtml(meta)}${fredyLink}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,12 +114,14 @@ function buildText(jobName, serviceName, o) {
|
||||
* @param {string} jobName
|
||||
* @param {string} serviceName
|
||||
* @param {Object} o - Listing object
|
||||
* @param baseUrl
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildCaptionPlain(jobName, serviceName, o) {
|
||||
function buildCaptionPlain(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}`.slice(0, 4096);
|
||||
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
|
||||
return `${jobName} (${serviceName})\n${title}\n${meta}\n\n${o.link || ''}${fredyLine}`.slice(0, 4096);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -125,10 +131,11 @@ function buildCaptionPlain(jobName, serviceName, o) {
|
||||
* @param {Object} o - Listing object
|
||||
* @returns {string}
|
||||
*/
|
||||
function buildTextPlain(jobName, serviceName, o) {
|
||||
function buildTextPlain(jobName, serviceName, o, baseUrl) {
|
||||
const title = shorten((o.title || '').replace(/\*/g, ''), 90);
|
||||
const meta = [o.address, o.price, o.size].filter(Boolean).join(' | ');
|
||||
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}`;
|
||||
const fredyLine = baseUrl && o.id ? `\nOpen in Fredy: ${baseUrl}/listings/listing/${o.id}` : '';
|
||||
return `${jobName} (${serviceName})\n${title}\n${o.link || ''}\n${meta}${fredyLine}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +150,7 @@ function buildTextPlain(jobName, serviceName, o) {
|
||||
* @param {string} params.jobKey - Storage job key to resolve the human readable job name.
|
||||
* @returns {Promise<Array<Response>>} Promise resolving when all send operations complete.
|
||||
*/
|
||||
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey }) => {
|
||||
export const send = ({ serviceName, newListings = [], notificationConfig, jobKey, baseUrl }) => {
|
||||
const adapterCfg = notificationConfig.find((adapter) => adapter.id === config.id);
|
||||
if (!adapterCfg || !adapterCfg.fields) {
|
||||
throw new Error(`Telegram adapter configuration missing for job '${jobKey || ''}'`);
|
||||
@@ -189,7 +196,7 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
const img = normalizeImageUrl(o.image);
|
||||
const textPayload = {
|
||||
chat_id: chatId,
|
||||
text: plainText ? buildTextPlain(jobName, serviceName, o) : buildText(jobName, serviceName, o),
|
||||
text: plainText ? buildTextPlain(jobName, serviceName, o, baseUrl) : buildText(jobName, serviceName, o, baseUrl),
|
||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||
disable_web_page_preview: true,
|
||||
...(message_thread_id ? { message_thread_id } : {}),
|
||||
@@ -204,7 +211,9 @@ export const send = ({ serviceName, newListings = [], notificationConfig, jobKey
|
||||
return await throttledCall('sendPhoto', {
|
||||
chat_id: chatId,
|
||||
photo: img,
|
||||
caption: plainText ? buildCaptionPlain(jobName, serviceName, o) : buildCaption(jobName, serviceName, o),
|
||||
caption: plainText
|
||||
? buildCaptionPlain(jobName, serviceName, o, baseUrl)
|
||||
: buildCaption(jobName, serviceName, o, baseUrl),
|
||||
...(plainText ? {} : { parse_mode: 'HTML' }),
|
||||
...(message_thread_id ? { message_thread_id } : {}),
|
||||
}).catch(async (e) => {
|
||||
|
||||
@@ -106,6 +106,9 @@
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<a href="{{this.link}}" class="btn" target="_blank">View Listing</a>
|
||||
{{#if this.fredyUrl}}
|
||||
<a href="{{this.fredyUrl}}" class="btn" style="background:#1a6fff;color:#ffffff;margin-left:8px;" target="_blank">Open in Fredy</a>
|
||||
{{/if}}
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -20,10 +20,10 @@ if (adapter.length === 0) {
|
||||
const findAdapter = (notificationAdapter) => {
|
||||
return adapter.find((a) => a.config.id === notificationAdapter.id);
|
||||
};
|
||||
export const send = (serviceName, newListings, notificationConfig, jobKey) => {
|
||||
export const send = (serviceName, newListings, notificationConfig, jobKey, baseUrl) => {
|
||||
//this is not being used in tests, therefore adapter are always set
|
||||
return notificationConfig
|
||||
.filter((notificationAdapter) => findAdapter(notificationAdapter) != null)
|
||||
.map((notificationAdapter) => findAdapter(notificationAdapter))
|
||||
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey }));
|
||||
.map((a) => a.send({ serviceName, newListings, notificationConfig, jobKey, baseUrl }));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { nanoid } from 'nanoid';
|
||||
import { guessBaseUrl } from '../../../../utils/detectBaseUrl.js';
|
||||
|
||||
export function up(db) {
|
||||
const exists = db.prepare(`SELECT 1 FROM settings WHERE name = 'baseUrl' AND user_id IS NULL LIMIT 1`).get();
|
||||
if (exists) return;
|
||||
|
||||
const portRow = db.prepare(`SELECT value FROM settings WHERE name = 'port' AND user_id IS NULL LIMIT 1`).get();
|
||||
let port = 9998;
|
||||
try {
|
||||
port = JSON.parse(portRow?.value ?? '9998');
|
||||
} catch {
|
||||
/* keep default */
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||
VALUES (@id, @create_date, 'baseUrl', @value, NULL)`,
|
||||
).run({ id: nanoid(), create_date: Date.now(), value: JSON.stringify(guessBaseUrl(port)) });
|
||||
}
|
||||
55
lib/utils/detectBaseUrl.js
Normal file
55
lib/utils/detectBaseUrl.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
|
||||
const DOCKER_BRIDGE_PREFIXES = ['172.17.', '172.18.', '172.19.', '172.20.'];
|
||||
|
||||
export function isRunningInDocker() {
|
||||
if (process.env.FREDY_DOCKER === '1') return true;
|
||||
try {
|
||||
fs.accessSync('/.dockerenv');
|
||||
return true;
|
||||
} catch {
|
||||
/* not docker */
|
||||
}
|
||||
try {
|
||||
const cgroup = fs.readFileSync('/proc/self/cgroup', 'utf8');
|
||||
return /docker|containerd|kubepods/.test(cgroup);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDockerBridgeIp(addr) {
|
||||
return DOCKER_BRIDGE_PREFIXES.some((prefix) => addr.startsWith(prefix));
|
||||
}
|
||||
|
||||
export function detectLocalIp() {
|
||||
if (isRunningInDocker()) {
|
||||
return process.env.FREDY_HOST_IP ?? '172.17.0.1';
|
||||
}
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const preferred of ['en0', 'eth0', 'wlan0', 'ens3', 'ens18']) {
|
||||
for (const entry of ifaces[preferred] ?? []) {
|
||||
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||
return entry.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const iface of Object.values(ifaces)) {
|
||||
for (const entry of iface ?? []) {
|
||||
if (entry.family === 'IPv4' && !entry.internal && !isDockerBridgeIp(entry.address)) {
|
||||
return entry.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 'localhost';
|
||||
}
|
||||
|
||||
export function guessBaseUrl(port) {
|
||||
return `http://${detectLocalIp()}:${port}`;
|
||||
}
|
||||
16
package.json
16
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "fredy",
|
||||
"version": "20.3.3",
|
||||
"version": "21.0.1",
|
||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||
"scripts": {
|
||||
"prepare": "husky",
|
||||
@@ -77,12 +77,12 @@
|
||||
"cheerio": "^1.2.0",
|
||||
"cookie-session": "2.1.1",
|
||||
"handlebars": "4.7.9",
|
||||
"maplibre-gl": "^5.23.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"nanoid": "5.1.9",
|
||||
"node-cron": "^4.2.1",
|
||||
"node-fetch": "3.3.2",
|
||||
"node-mailjet": "6.0.11",
|
||||
"nodemailer": "^8.0.5",
|
||||
"nodemailer": "^8.0.6",
|
||||
"p-throttle": "^8.1.0",
|
||||
"package-up": "^5.0.0",
|
||||
"puppeteer": "^24.42.0",
|
||||
@@ -93,14 +93,14 @@
|
||||
"react-chartjs-2": "^5.3.1",
|
||||
"react-dom": "19.2.5",
|
||||
"react-range-slider-input": "^3.3.5",
|
||||
"react-router": "7.14.1",
|
||||
"react-router-dom": "7.14.1",
|
||||
"react-router": "7.14.2",
|
||||
"react-router-dom": "7.14.2",
|
||||
"resend": "^6.12.2",
|
||||
"restana": "5.2.0",
|
||||
"restana": "6.0.0",
|
||||
"semver": "^7.7.4",
|
||||
"serve-static": "2.2.1",
|
||||
"slack": "11.0.2",
|
||||
"vite": "8.0.9",
|
||||
"vite": "8.0.10",
|
||||
"x-var": "^3.0.1",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
@@ -121,6 +121,6 @@
|
||||
"lint-staged": "16.4.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "3.8.3",
|
||||
"vitest": "^4.1.4"
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ export function getUserSettings(userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getSettings() {
|
||||
return { baseUrl: '' };
|
||||
}
|
||||
|
||||
export const updateListingDistance = (id, distance) => {
|
||||
// noop
|
||||
};
|
||||
|
||||
132
test/utils/detectBaseUrl.test.js
Normal file
132
test/utils/detectBaseUrl.test.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('fs');
|
||||
vi.mock('os');
|
||||
|
||||
import * as fsMock from 'fs';
|
||||
import * as osMock from 'os';
|
||||
import { isRunningInDocker, detectLocalIp, guessBaseUrl } from '../../lib/utils/detectBaseUrl.js';
|
||||
|
||||
describe('detectBaseUrl', () => {
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
originalEnv = { ...process.env };
|
||||
vi.clearAllMocks();
|
||||
delete process.env.FREDY_DOCKER;
|
||||
delete process.env.FREDY_HOST_IP;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('isRunningInDocker', () => {
|
||||
it('returns true when FREDY_DOCKER=1', () => {
|
||||
process.env.FREDY_DOCKER = '1';
|
||||
expect(isRunningInDocker()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no Docker signals present', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
expect(isRunningInDocker()).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when /.dockerenv is accessible', () => {
|
||||
vi.mocked(fsMock.accessSync).mockReturnValue(undefined);
|
||||
expect(isRunningInDocker()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when /proc/self/cgroup contains docker', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockReturnValue('12:cpu:/docker/abc123');
|
||||
expect(isRunningInDocker()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when /proc/self/cgroup contains containerd', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error('not found');
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockReturnValue('0::/../containerd/abc');
|
||||
expect(isRunningInDocker()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectLocalIp', () => {
|
||||
it('returns 172.17.0.1 when running in Docker (default)', () => {
|
||||
process.env.FREDY_DOCKER = '1';
|
||||
expect(detectLocalIp()).toBe('172.17.0.1');
|
||||
});
|
||||
|
||||
it('returns FREDY_HOST_IP when set in Docker', () => {
|
||||
process.env.FREDY_DOCKER = '1';
|
||||
process.env.FREDY_HOST_IP = '192.168.1.50';
|
||||
expect(detectLocalIp()).toBe('192.168.1.50');
|
||||
});
|
||||
|
||||
it('skips docker bridge IPs and returns real LAN IP', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||
docker0: [{ family: 'IPv4', address: '172.17.0.1', internal: false }],
|
||||
en0: [{ family: 'IPv4', address: '192.168.1.100', internal: false }],
|
||||
});
|
||||
expect(detectLocalIp()).toBe('192.168.1.100');
|
||||
});
|
||||
|
||||
it('prefers en0 over arbitrary interfaces', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||
utun3: [{ family: 'IPv4', address: '10.8.0.1', internal: false }],
|
||||
en0: [{ family: 'IPv4', address: '192.168.178.50', internal: false }],
|
||||
});
|
||||
expect(detectLocalIp()).toBe('192.168.178.50');
|
||||
});
|
||||
|
||||
it('falls back to localhost when no suitable interface found', () => {
|
||||
vi.mocked(fsMock.accessSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(fsMock.readFileSync).mockImplementation(() => {
|
||||
throw new Error();
|
||||
});
|
||||
vi.mocked(osMock.networkInterfaces).mockReturnValue({
|
||||
lo: [{ family: 'IPv4', address: '127.0.0.1', internal: true }],
|
||||
});
|
||||
expect(detectLocalIp()).toBe('localhost');
|
||||
});
|
||||
});
|
||||
|
||||
describe('guessBaseUrl', () => {
|
||||
it('returns correctly formatted URL', () => {
|
||||
process.env.FREDY_DOCKER = '1';
|
||||
expect(guessBaseUrl(9998)).toBe('http://172.17.0.1:9998');
|
||||
});
|
||||
|
||||
it('includes custom port', () => {
|
||||
process.env.FREDY_DOCKER = '1';
|
||||
expect(guessBaseUrl(8080)).toBe('http://172.17.0.1:8080');
|
||||
});
|
||||
});
|
||||
});
|
||||
229
tools/devMock.js
Normal file
229
tools/devMock.js
Normal 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'));
|
||||
@@ -1,3 +1,5 @@
|
||||
@import './tokens.less';
|
||||
|
||||
.app {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
@@ -14,18 +16,14 @@
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
padding: 24px;
|
||||
background-color: var(--semi-color-bg-0);
|
||||
padding: @space-6;
|
||||
background-color: transparent;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media (max-width: 768px) {
|
||||
padding: 12px;
|
||||
padding: @space-3;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,15 @@
|
||||
import { HashRouter } from 'react-router-dom';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
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 './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 root = createRoot(container);
|
||||
|
||||
|
||||
@@ -1,16 +1,117 @@
|
||||
@import './tokens.less';
|
||||
|
||||
body,
|
||||
html {
|
||||
margin: 0;
|
||||
height: 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 {
|
||||
background-color: #2b2b2b !important;
|
||||
color: #fff !important;
|
||||
background-color: rgba(255,255,255,0.06) !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 {
|
||||
background-color: #333333 !important;
|
||||
// Scrollbar
|
||||
::-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);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
{
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876511",
|
||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876542",
|
||||
"content":
|
||||
[
|
||||
{
|
||||
"title": "Fredy goes AI",
|
||||
"text": "With Fredy v20.0.0, we are introducing Fredy’s own MCP server. This brings a powerful new capability: you can connect your local LLM directly to Fredy and explore the data it collects in a much more flexible way.<br/><br/>The MCP server exposes Fredy’s tools and findings through a structured interface, allowing your LLM to query listings, inspect collected details, and analyze results programmatically. Instead of manually searching through the data, you can simply ask your model questions and let it dig into what Fredy has discovered for you.<br/><br/>In practice, this means your local LLM can interact with Fredy almost like an assistant: investigating properties, summarizing listings, filtering results, or helping you identify interesting opportunities based on the data Fredy gathered.",
|
||||
"media": "news.mp4"
|
||||
"title": "Open in...Fredy ;)",
|
||||
"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 let’s be honest, you probably know better. Take a quick look at the baseUrl in the system settings and fix it if it looks off."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 14rem;
|
||||
opacity: .7;
|
||||
color: #94a3b8;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +1,34 @@
|
||||
@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 {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
margin-bottom: 16px;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
background-color: #181b26;
|
||||
border: 1px solid #232735;
|
||||
border-radius: 10px;
|
||||
--pulse-color: rgba(255, 255, 255, 0.08);
|
||||
height: 112px;
|
||||
border-radius: @radius-card !important;
|
||||
border: 1px solid @color-border !important;
|
||||
background-color: @color-surface !important;
|
||||
transition: box-shadow @transition-card;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
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 {
|
||||
font-size: 20px;
|
||||
font-size: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--card-accent, #94a3b8);
|
||||
color: var(--card-accent, @color-gray-text);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--semi-color-text-2) !important;
|
||||
font-size: 12px !important;
|
||||
color: rgba(148, 163, 184, 0.7) !important;
|
||||
font-size: @text-xs !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
@@ -49,61 +38,50 @@
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: var(--card-accent, var(--semi-color-text-0));
|
||||
color: var(--card-accent, @color-text);
|
||||
}
|
||||
|
||||
&__desc {
|
||||
color: var(--semi-color-text-3) !important;
|
||||
color: @color-faint !important;
|
||||
font-size: @text-xs;
|
||||
}
|
||||
|
||||
&.blue {
|
||||
--pulse-color: @color-blue-border;
|
||||
--card-accent: @color-blue-text;
|
||||
background-color: @color-blue-bg;
|
||||
border-color: @color-blue-border;
|
||||
box-shadow: 0 2px 16px -6px @color-blue-border;
|
||||
--card-glow: @color-blue-border;
|
||||
background-color: @color-blue-bg !important;
|
||||
border-color: @color-blue-border !important;
|
||||
animation: card-glow-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
&.orange {
|
||||
--pulse-color: @color-orange-border;
|
||||
--card-accent: @color-orange-text;
|
||||
background-color: @color-orange-bg;
|
||||
border-color: @color-orange-border;
|
||||
box-shadow: 0 2px 16px -6px @color-orange-border;
|
||||
--card-glow: @color-orange-border;
|
||||
background-color: @color-orange-bg !important;
|
||||
border-color: @color-orange-border !important;
|
||||
animation: card-glow-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
&.green {
|
||||
--pulse-color: @color-green-border;
|
||||
--card-accent: @color-green-text;
|
||||
background-color: @color-green-bg;
|
||||
border-color: @color-green-border;
|
||||
box-shadow: 0 2px 16px -6px @color-green-border;
|
||||
--card-glow: @color-green-border;
|
||||
background-color: @color-green-bg !important;
|
||||
border-color: @color-green-border !important;
|
||||
animation: card-glow-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
&.purple {
|
||||
--pulse-color: @color-purple-border;
|
||||
--card-accent: @color-purple-text;
|
||||
background-color: @color-purple-bg;
|
||||
border-color: @color-purple-border;
|
||||
box-shadow: 0 2px 16px -6px @color-purple-border;
|
||||
--card-glow: @color-purple-border;
|
||||
background-color: @color-purple-bg !important;
|
||||
border-color: @color-purple-border !important;
|
||||
animation: card-glow-rotate 8s linear infinite;
|
||||
}
|
||||
|
||||
&.gray {
|
||||
--pulse-color: @color-gray-border;
|
||||
--card-accent: @color-gray-text;
|
||||
background-color: @color-gray-bg;
|
||||
border-color: @color-gray-border;
|
||||
box-shadow: 0 2px 16px -6px @color-gray-border;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 0.1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.4;
|
||||
--card-glow: @color-gray-border;
|
||||
background-color: @color-gray-bg !important;
|
||||
border-color: @color-gray-border !important;
|
||||
animation: card-glow-rotate 8s linear infinite;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1 @@
|
||||
@color-blue-bg: rgba(96, 165, 250, 0.10);
|
||||
@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;
|
||||
@import '../../tokens.less';
|
||||
|
||||
@@ -6,15 +6,7 @@
|
||||
import { Card, Typography, Space } from '@douyinfe/semi-ui-19';
|
||||
import './DashboardCard.less';
|
||||
|
||||
export default function KpiCard({
|
||||
title,
|
||||
icon,
|
||||
value,
|
||||
valueFontSize = '1.5rem',
|
||||
description,
|
||||
color = 'gray',
|
||||
children,
|
||||
}) {
|
||||
export default function KpiCard({ title, icon, value, description, color = 'gray', children }) {
|
||||
const { Text } = Typography;
|
||||
return (
|
||||
<Card className={`dashboard-card ${color}`} bodyStyle={{ padding: '16px' }}>
|
||||
@@ -26,12 +18,12 @@ export default function KpiCard({
|
||||
</Text>
|
||||
</Space>
|
||||
<div className="dashboard-card__content">
|
||||
<div className="dashboard-card__value" style={{ fontSize: valueFontSize }}>
|
||||
<div className="dashboard-card__value">
|
||||
{value}
|
||||
{children}
|
||||
</div>
|
||||
{description && (
|
||||
<Text size="small" type="tertiary" className="dashboard-card__desc">
|
||||
<Text size="small" className="dashboard-card__desc">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -5,23 +5,21 @@
|
||||
|
||||
import './FredyFooter.less';
|
||||
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() {
|
||||
const { Text } = Typography;
|
||||
const { Footer } = Layout;
|
||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||
|
||||
return (
|
||||
<Footer className="fredyFooter">
|
||||
<Space split={<Divider layout="vertical" />}>
|
||||
<Text type="tertiary" size="small">
|
||||
Fredy V{version?.localFredyVersion || 'N/A'}
|
||||
</Text>
|
||||
<Text size="small" link={{ href: 'https://github.com/orangecoding', target: '_blank' }}>
|
||||
Made with ❤️
|
||||
</Text>
|
||||
</Space>
|
||||
<span className="fredyFooter__version">Fredy v{version?.localFredyVersion || 'N/A'}</span>
|
||||
<span className="fredyFooter__credit">
|
||||
Made with ❤️ by{' '}
|
||||
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
|
||||
Christian Kellner
|
||||
</a>
|
||||
</span>
|
||||
</Footer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,34 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.fredyFooter {
|
||||
background-color: var(--semi-color-bg-1);
|
||||
background-color: @color-base;
|
||||
border-top: 1px solid @color-border;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 1rem;
|
||||
height: 32px;
|
||||
border-top: 1px solid var(--semi-color-border);
|
||||
z-index: 1000;
|
||||
position: relative;
|
||||
padding: 10px 24px;
|
||||
height: 36px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import {
|
||||
IconBriefcase,
|
||||
IconBell,
|
||||
IconSearch,
|
||||
IconPlusCircle,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
IconHome,
|
||||
@@ -202,10 +201,6 @@ const JobGrid = () => {
|
||||
return (
|
||||
<div className="jobGrid">
|
||||
<div className="jobGrid__topbar">
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||
New Job
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
className="jobGrid__topbar__search"
|
||||
prefix={<IconSearch />}
|
||||
|
||||
@@ -1,18 +1,33 @@
|
||||
@import '../../cards/DashboardCardColors.less';
|
||||
@import '../../../tokens.less';
|
||||
|
||||
.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 {
|
||||
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);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 50%);
|
||||
background-color: @color-surface !important;
|
||||
border: 1px solid @color-border !important;
|
||||
border-radius: @radius-card !important;
|
||||
transition: transform @transition-card, box-shadow @transition-card;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
box-shadow: 0 4px 20px -4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
&__header {
|
||||
@@ -35,10 +50,10 @@
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--semi-color-text-3);
|
||||
background-color: rgba(251,113,133,0.7);
|
||||
|
||||
&--active {
|
||||
background-color: #21aa21;
|
||||
background-color: rgba(52,211,153,0.8);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,21 +67,21 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--semi-border-radius-small);
|
||||
border-radius: @radius-chip;
|
||||
padding: 10px 4px 8px;
|
||||
|
||||
&__number {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: var(--semi-color-text-0);
|
||||
color: @color-text;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 11px;
|
||||
color: var(--semi-color-text-3);
|
||||
font-size: @text-xs;
|
||||
color: @color-faint;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
margin-bottom: 0 !important;
|
||||
color: @color-text !important;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 2rem;
|
||||
margin-top: @space-4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.jobPopoverContent {
|
||||
padding: .4rem;
|
||||
color: var(--semi-color-white);
|
||||
font-size: @text-sm;
|
||||
padding: 4px 8px;
|
||||
color: @color-text;
|
||||
}
|
||||
|
||||
@@ -10,34 +10,16 @@ import {
|
||||
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 { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconBriefcase,
|
||||
IconCart,
|
||||
IconClock,
|
||||
IconDelete,
|
||||
IconLink,
|
||||
IconMapPin,
|
||||
IconStar,
|
||||
IconStarStroked,
|
||||
IconSearch,
|
||||
IconActivity,
|
||||
IconEyeOpened,
|
||||
IconArrowUp,
|
||||
IconArrowDown,
|
||||
@@ -53,8 +35,6 @@ import { debounce } from '../../../utils';
|
||||
import './ListingsGrid.less';
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ListingsGrid = () => {
|
||||
const listingsData = useSelector((state) => state.listingsData);
|
||||
const providers = useSelector((state) => state.provider);
|
||||
@@ -137,10 +117,6 @@ const ListingsGrid = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const cap = (val) => {
|
||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="listingsGrid">
|
||||
<div className="listingsGrid__topbar">
|
||||
@@ -238,111 +214,107 @@ const ListingsGrid = () => {
|
||||
description="No listings available yet..."
|
||||
/>
|
||||
)}
|
||||
<Row gutter={[16, 16]}>
|
||||
<div className="listingsGrid__grid">
|
||||
{(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' }}
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
cover={
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="listingsGrid__imageContainer">
|
||||
<Image
|
||||
src={item.image_url || no_image}
|
||||
fallback={no_image}
|
||||
width="100%"
|
||||
height={180}
|
||||
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)}
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
<Divider margin=".6rem" />
|
||||
<div className="listingsGrid__actions">
|
||||
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
|
||||
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||
<IconLink />
|
||||
</a>
|
||||
</div>
|
||||
<Button
|
||||
type="secondary"
|
||||
size="small"
|
||||
title="View Details"
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
icon={<IconEyeOpened />}
|
||||
/>
|
||||
<Button
|
||||
title="Remove"
|
||||
type="danger"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
/>
|
||||
<div
|
||||
key={item.id}
|
||||
className="listingsGrid__card"
|
||||
style={{ cursor: 'pointer' }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate(`/listings/listing/${item.id}`)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') navigate(`/listings/listing/${item.id}`);
|
||||
}}
|
||||
>
|
||||
<div className="listingsGrid__card__image-wrapper">
|
||||
<img
|
||||
src={item.image_url || no_image}
|
||||
alt={item.title}
|
||||
onError={(e) => {
|
||||
e.target.src = no_image;
|
||||
}}
|
||||
/>
|
||||
{!item.is_active && (
|
||||
<div className="listingsGrid__card__inactive-watermark">
|
||||
<span>Inactive</span>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="listingsGrid__card__star"
|
||||
onClick={(e) => handleWatch(e, item)}
|
||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
||||
>
|
||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="listingsGrid__card__body">
|
||||
<div className="listingsGrid__card__title" title={item.title}>
|
||||
{item.title}
|
||||
</div>
|
||||
</Card>
|
||||
</Col>
|
||||
{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
|
||||
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();
|
||||
navigate(`/listings/listing/${item.id}`);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content="Remove">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<IconDelete />}
|
||||
style={{ color: '#fb7185' }}
|
||||
theme="borderless"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setListingToDelete(item.id);
|
||||
setDeleteModalVisible(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
{(listingsData?.result || []).length > 0 && (
|
||||
<div className="listingsGrid__pagination">
|
||||
<Pagination
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
@import '../../cards/DashboardCardColors.less';
|
||||
@import '../../../tokens.less';
|
||||
|
||||
.listingsGrid {
|
||||
&__imageContainer {
|
||||
position: relative;
|
||||
height: 180px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__topbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
gap: @space-3;
|
||||
margin-bottom: @space-4;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.listingsGrid__topbar__search {
|
||||
flex: 1;
|
||||
&__search {
|
||||
min-width: 200px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -37,149 +31,151 @@
|
||||
}
|
||||
}
|
||||
|
||||
&__watchButton {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background-color: white !important;
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--semi-color-fill-0) !important;
|
||||
}
|
||||
}
|
||||
|
||||
&__statusTag {
|
||||
position: absolute;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__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);
|
||||
background: @color-elevated !important;
|
||||
border: 1px solid @color-border !important;
|
||||
border-radius: @radius-card !important;
|
||||
overflow: hidden;
|
||||
transition: transform @transition-card, box-shadow @transition-card;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--semi-shadow-elevated);
|
||||
background-color: rgba(36, 36, 36, 1);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 24px -4px rgba(0,0,0,0.6);
|
||||
}
|
||||
|
||||
&--inactive {
|
||||
&__image-wrapper {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
|
||||
.listingsGrid__imageContainer,
|
||||
.listingsGrid__content {
|
||||
opacity: 0.6;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&__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;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background @transition-fast;
|
||||
padding: 0;
|
||||
|
||||
&:hover {
|
||||
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 {
|
||||
font-weight: 700;
|
||||
font-size: @text-sm;
|
||||
color: @color-text;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: @text-base;
|
||||
font-weight: 600;
|
||||
color: @color-success;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
font-size: @text-xs;
|
||||
color: @color-muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.semi-icon {
|
||||
font-size: 11px;
|
||||
color: @color-faint;
|
||||
}
|
||||
}
|
||||
|
||||
&__provider {
|
||||
font-size: @text-xs;
|
||||
color: @color-faint;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid @color-border;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
|
||||
button {
|
||||
flex: 1;
|
||||
border: none !important;
|
||||
border-radius: @radius-chip !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__inactiveOverlay {
|
||||
position: absolute;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
color: var(--semi-color-danger);
|
||||
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 {
|
||||
color: var(--semi-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: block;
|
||||
height: 1.5em;
|
||||
}
|
||||
|
||||
&__pagination {
|
||||
margin-top: 2rem;
|
||||
margin-top: @space-4;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__price {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: @color-green-text;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 8px 0 6px;
|
||||
}
|
||||
|
||||
&__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__setupButton {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
&__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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
* 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 } = {}) {
|
||||
const { Title } = Typography;
|
||||
export default function Headline({ text, actions } = {}) {
|
||||
return (
|
||||
<Title heading={size} style={{ marginBottom: '1rem' }}>
|
||||
{text}
|
||||
</Title>
|
||||
<div className="page-heading">
|
||||
<div className="page-heading__row">
|
||||
<h1 className="page-heading__title">{text}</h1>
|
||||
{actions && <div>{actions}</div>}
|
||||
</div>
|
||||
<div className="page-heading__line" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
27
ui/src/components/headline/Headline.less
Normal file
27
ui/src/components/headline/Headline.less
Normal 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%;
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,20 @@
|
||||
* 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 { IconUser } from '@douyinfe/semi-icons';
|
||||
|
||||
const Logout = function Logout({ text }) {
|
||||
const handleLogout = async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconUser />}
|
||||
type="danger"
|
||||
theme="solid"
|
||||
onClick={async () => {
|
||||
await xhrPost('/api/login/logout');
|
||||
location.reload();
|
||||
}}
|
||||
>
|
||||
{text && 'Logout'}
|
||||
</Button>
|
||||
</div>
|
||||
<button className={`navigate__logout-btn${!text ? ' navigate__logout-btn--icon-only' : ''}`} onClick={handleLogout}>
|
||||
<IconUser size="default" />
|
||||
{text && 'Logout'}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,218 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.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;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
padding-bottom: 12px;
|
||||
padding: 20px 16px 16px;
|
||||
min-height: 64px;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
transition: width @transition-fast, opacity @transition-fast;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__footer {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 logoWhite from '../../assets/logo_white.png';
|
||||
import heart from '../../assets/heart.png';
|
||||
@@ -65,20 +65,32 @@ export default function Navigation({ isAdmin }) {
|
||||
return '/' + split[0];
|
||||
}
|
||||
|
||||
const sidebarWidth = collapsed ? '60px' : '220px';
|
||||
|
||||
return (
|
||||
<Nav
|
||||
style={{ height: '100%', maxWidth: collapsed ? '60px' : '240px' }}
|
||||
style={{ height: '100%', width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }}
|
||||
items={items}
|
||||
isCollapsed={collapsed}
|
||||
selectedKeys={[parsePathName(location.pathname)]}
|
||||
onSelect={(key) => {
|
||||
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={
|
||||
<Nav.Footer className="navigate__footer">
|
||||
<Nav.Footer className={`navigate__footer${collapsed ? ' navigate__footer--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>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,89 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.segmentParts {
|
||||
border: 1px solid #323232 !important;
|
||||
border-radius: .9rem !important;
|
||||
color: rgba(var(--semi-grey-8), 1);
|
||||
background: rgb(53, 54, 60);
|
||||
margin: 0 0 1rem 0;
|
||||
background: rgba(255,255,255,0.03) !important;
|
||||
border: 1px solid @color-border !important;
|
||||
border-radius: @radius-card !important;
|
||||
margin-bottom: @space-4;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,11 @@
|
||||
|
||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||
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';
|
||||
|
||||
const empty = (
|
||||
<Empty
|
||||
image={<IllustrationNoResult />}
|
||||
darkModeImage={<IllustrationNoResultDark />}
|
||||
description={'No users found.'}
|
||||
/>
|
||||
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
|
||||
);
|
||||
|
||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
||||
@@ -23,47 +19,73 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
||||
empty={empty}
|
||||
columns={[
|
||||
{
|
||||
title: 'Username',
|
||||
title: 'User',
|
||||
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',
|
||||
dataIndex: 'lastLogin',
|
||||
render: (value) => {
|
||||
return format(value);
|
||||
},
|
||||
render: (value) => format(value),
|
||||
},
|
||||
{
|
||||
title: 'Number of jobs',
|
||||
title: 'Jobs',
|
||||
dataIndex: 'numberOfJobs',
|
||||
},
|
||||
{
|
||||
title: 'MCP Token',
|
||||
dataIndex: 'mcpToken',
|
||||
render: (value) => {
|
||||
return (
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.85em', wordBreak: 'break-all' }}>
|
||||
{value || '---'}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
render: (value) => (
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
fontSize: '0.85em',
|
||||
wordBreak: 'break-all',
|
||||
color: '#505050',
|
||||
}}
|
||||
>
|
||||
{value || '---'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'tools',
|
||||
render: (value, user) => {
|
||||
return (
|
||||
<div style={{ float: 'right' }}>
|
||||
<Button
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={() => onUserRemoval(user.id)}
|
||||
style={{ marginRight: '1rem' }}
|
||||
/>
|
||||
<Button type="primary" icon={<IconEdit />} onClick={() => onUserEdit(user.id)} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
<Button
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(251,113,133,0.2)',
|
||||
color: '#fb7185',
|
||||
}}
|
||||
icon={<IconDelete />}
|
||||
onClick={() => onUserRemoval(record.id)}
|
||||
/>
|
||||
<Button type="primary" theme="solid" icon={<IconEdit />} onClick={() => onUserEdit(record.id)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={user}
|
||||
|
||||
69
ui/src/tokens.less
Normal file
69
ui/src/tokens.less
Normal 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;
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
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 {
|
||||
IconTerminal,
|
||||
IconStar,
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSelector, useActions } from '../../services/state/store';
|
||||
import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
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 pieData = dashboard?.pie || [];
|
||||
const { Text } = Typography;
|
||||
|
||||
return (
|
||||
<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">
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
@@ -51,7 +53,6 @@ export default function Dashboard() {
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
@@ -69,7 +70,6 @@ export default function Dashboard() {
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
@@ -96,7 +96,7 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text className="dashboard__section-label">Overview</Text>
|
||||
<div className="dashboard__section-label">Overview</div>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
@@ -132,10 +132,9 @@ export default function Dashboard() {
|
||||
value={`${
|
||||
!kpis.medianPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.medianPriceOfListings)
|
||||
: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
||||
kpis.medianPriceOfListings,
|
||||
)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Median Price of listings"
|
||||
@@ -143,7 +142,7 @@ export default function Dashboard() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text className="dashboard__section-label">Provider Insights</Text>
|
||||
<div className="dashboard__section-label">Provider Insights</div>
|
||||
<div className="dashboard__pie-wrapper">
|
||||
<PieChartCard data={pieData} />
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -5,13 +7,13 @@
|
||||
|
||||
&__section-label {
|
||||
display: block;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
font-size: @text-xs;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5a6478 !important;
|
||||
color: @color-faint;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 4px;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
&__row {
|
||||
@@ -22,9 +24,8 @@
|
||||
&__pie-wrapper {
|
||||
background: #23242a;
|
||||
border: 1px solid #37404e;
|
||||
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
border-radius: @radius-card;
|
||||
padding: 28px;
|
||||
max-height: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
} from '../../services/backupRestoreClient';
|
||||
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
||||
import { debounce } from '../../utils';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
import './GeneralSettings.less';
|
||||
|
||||
function formatFromTimestamp(ts) {
|
||||
@@ -61,6 +62,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
const [demoMode, setDemoMode] = React.useState(null);
|
||||
const [analyticsEnabled, setAnalyticsEnabled] = React.useState(null);
|
||||
const [sqlitePath, setSqlitePath] = React.useState(null);
|
||||
const [baseUrl, setBaseUrl] = React.useState('');
|
||||
const fileInputRef = React.useRef(null);
|
||||
const [restoreModalVisible, setRestoreModalVisible] = React.useState(false);
|
||||
const [precheckInfo, setPrecheckInfo] = React.useState(null);
|
||||
@@ -94,6 +96,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
setAnalyticsEnabled(settings?.analyticsEnabled || false);
|
||||
setDemoMode(settings?.demoMode || false);
|
||||
setSqlitePath(settings?.sqlitepath);
|
||||
setBaseUrl(settings?.baseUrl ?? '');
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -137,6 +140,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
demoMode,
|
||||
analyticsEnabled,
|
||||
sqlitepath: sqlitePath,
|
||||
baseUrl,
|
||||
});
|
||||
} catch (exception) {
|
||||
console.error(exception);
|
||||
@@ -241,6 +245,7 @@ const GeneralSettings = function GeneralSettings() {
|
||||
|
||||
return (
|
||||
<div className="generalSettings">
|
||||
<Headline text="Settings" />
|
||||
{!loading && (
|
||||
<>
|
||||
<Tabs type="line">
|
||||
@@ -266,6 +271,13 @@ const GeneralSettings = function GeneralSettings() {
|
||||
/>
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="Base URL"
|
||||
helpText="Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications."
|
||||
>
|
||||
<Input type="text" placeholder="Base-Url" value={baseUrl} onChange={(value) => setBaseUrl(value)} />
|
||||
</SegmentPart>
|
||||
|
||||
<SegmentPart
|
||||
name="SQLite Database Path"
|
||||
helpText="The directory where Fredy stores its SQLite database files."
|
||||
|
||||
@@ -1,17 +1,73 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.generalSettings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__tab-content {
|
||||
padding: 20px 0;
|
||||
max-width: 860px;
|
||||
padding: @space-4 0;
|
||||
}
|
||||
|
||||
&__timePickerContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
gap: @space-3;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
&__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;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,25 @@
|
||||
* 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 Headline from '../../components/headline/Headline.jsx';
|
||||
import './Jobs.less';
|
||||
|
||||
export default function Jobs() {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<div className="jobs">
|
||||
<Headline
|
||||
text="Jobs"
|
||||
actions={
|
||||
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||
New Job
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<JobGrid />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.jobs {
|
||||
&__newButton {
|
||||
margin-top: 1rem !important;
|
||||
float: left;
|
||||
margin-bottom: 1rem !important;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Divider, Input, Switch, Button, TagInput, Toast, Select } from '@douyin
|
||||
import './JobMutation.less';
|
||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconBell,
|
||||
IconBriefcase,
|
||||
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>
|
||||
<SegmentPart name="Name" Icon={IconPaperclip}>
|
||||
<Input
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
@import '../../../tokens.less';
|
||||
|
||||
.jobMutation {
|
||||
&__newButton {
|
||||
float: right;
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: @space-4;
|
||||
}
|
||||
|
||||
&__specFilter {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
gap: @space-4;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__specFilterItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
gap: @space-2;
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
&__specFilterLabel {
|
||||
font-weight: 500;
|
||||
font-size: @text-sm;
|
||||
color: @color-muted;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: @space-3;
|
||||
margin-top: @space-4;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import * as timeService from '../../services/time/timeService.js';
|
||||
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
import './ListingDetail.less';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -278,7 +279,7 @@ export default function ListingDetail() {
|
||||
},
|
||||
{
|
||||
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 />,
|
||||
},
|
||||
{
|
||||
@@ -290,40 +291,45 @@ export default function ListingDetail() {
|
||||
|
||||
return (
|
||||
<div className="listing-detail">
|
||||
<div className="listing-detail__back">
|
||||
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
|
||||
Back
|
||||
</Button>
|
||||
</div>
|
||||
<Headline
|
||||
text={listing?.title || 'Listing Detail'}
|
||||
actions={
|
||||
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
|
||||
Back
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card className="listing-detail__card">
|
||||
<div className="listing-detail__header">
|
||||
<Space vertical align="start" spacing="tight">
|
||||
<Title heading={2} className="listing-detail__title">
|
||||
{listing.title}
|
||||
</Title>
|
||||
<Space align="center">
|
||||
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
<Text type="secondary">{listing.address || 'No address provided'}</Text>
|
||||
</Space>
|
||||
<Space align="center">
|
||||
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
||||
{listing.address ? (
|
||||
<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 wrap className="listing-detail__header-actions">
|
||||
<Button
|
||||
icon={
|
||||
listing.isWatched === 1 ? (
|
||||
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
|
||||
) : (
|
||||
<IconStarStroked />
|
||||
)
|
||||
}
|
||||
icon={listing.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||
onClick={handleWatch}
|
||||
theme="light"
|
||||
theme="borderless"
|
||||
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
|
||||
>
|
||||
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
|
||||
</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
|
||||
</Text>
|
||||
</a>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.listing-detail {
|
||||
padding-bottom: 2rem;
|
||||
|
||||
&__back {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
&__card {
|
||||
background-color: rgba(36, 36, 36, 0.9);
|
||||
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 {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
@@ -69,7 +59,61 @@
|
||||
img {
|
||||
width: 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;
|
||||
}
|
||||
}
|
||||
|
||||
&__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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,13 @@
|
||||
*/
|
||||
|
||||
import ListingsGrid from '../../components/grid/listings/ListingsGrid.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
export default function Listings() {
|
||||
return <ListingsGrid />;
|
||||
return (
|
||||
<>
|
||||
<Headline text="Listings" />
|
||||
<ListingsGrid />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { xhrDelete } from '../../services/xhr.js';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||
import Map from '../../components/map/Map.jsx';
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
|
||||
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||
|
||||
@@ -354,127 +355,130 @@ export default function MapView() {
|
||||
}, [listings, priceRange, homeAddress, distanceFilter]);
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
{!homeAddress && (
|
||||
<>
|
||||
<Headline text="Map View" />
|
||||
<div className="map-view-container">
|
||||
{!homeAddress && (
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="warning"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '8px' }}
|
||||
description={
|
||||
<span>
|
||||
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
||||
filter.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="warning"
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '8px' }}
|
||||
description={
|
||||
<span>
|
||||
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
||||
filter.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '8px' }}
|
||||
description="Only listings with valid addresses are shown on this map."
|
||||
/>
|
||||
|
||||
<div className="map-view-container__map-wrapper">
|
||||
<Map
|
||||
mapContainerRef={mapContainer}
|
||||
style={style}
|
||||
show3dBuildings={show3dBuildings}
|
||||
onMapReady={handleMapReady}
|
||||
description="Only listings with valid addresses are shown on this map."
|
||||
/>
|
||||
|
||||
{/* Floating filter panel */}
|
||||
<div className="map-view-container__floating-panel">
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Job
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="All jobs"
|
||||
showClear
|
||||
size="small"
|
||||
onChange={(val) => setJobId(val)}
|
||||
value={jobId}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<div className="map-view-container__map-wrapper">
|
||||
<Map
|
||||
mapContainerRef={mapContainer}
|
||||
style={style}
|
||||
show3dBuildings={show3dBuildings}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Distance
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="None"
|
||||
size="small"
|
||||
onChange={(val) => setDistanceFilter(val)}
|
||||
value={distanceFilter}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value={0}>None</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
{/* Floating filter panel */}
|
||||
<div className="map-view-container__floating-panel">
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Job
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="All jobs"
|
||||
showClear
|
||||
size="small"
|
||||
onChange={(val) => setJobId(val)}
|
||||
value={jobId}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Price (€)
|
||||
</Text>
|
||||
<div className="map-view-container__price-slider">
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]}</span>
|
||||
<span>{priceRange[1]}</span>
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Distance
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="None"
|
||||
size="small"
|
||||
onChange={(val) => setDistanceFilter(val)}
|
||||
value={distanceFilter}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value={0}>None</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Price (€)
|
||||
</Text>
|
||||
<div className="map-view-container__price-slider">
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]}</span>
|
||||
<span>{priceRange[1]}</span>
|
||||
</div>
|
||||
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
|
||||
</div>
|
||||
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Style
|
||||
</Text>
|
||||
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
3D Buildings
|
||||
</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={show3dBuildings}
|
||||
onChange={(v) => setShow3dBuildings(v)}
|
||||
disabled={style === 'SATELLITE'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Style
|
||||
</Text>
|
||||
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
3D Buildings
|
||||
</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={show3dBuildings}
|
||||
onChange={(v) => setShow3dBuildings(v)}
|
||||
disabled={style === 'SATELLITE'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
onCancel={() => {
|
||||
setDeleteModalVisible(false);
|
||||
setListingToDelete(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@import '../../tokens.less';
|
||||
|
||||
.map-view-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -21,11 +23,11 @@
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: rgba(13, 15, 20, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid #262a3a;
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 25, 38, 0.95);
|
||||
backdrop-filter: blur(8px);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
border: 1px solid @color-border;
|
||||
border-radius: @radius-card;
|
||||
padding: 14px 16px;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
@@ -183,13 +185,19 @@
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: #0ab5b3;
|
||||
background: @color-accent;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
import { useState } from 'react';
|
||||
import { IconHorn } from '@douyinfe/semi-icons';
|
||||
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 Headline from '../../../components/headline/Headline.jsx';
|
||||
|
||||
export default function WatchlistManagement() {
|
||||
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
|
||||
@@ -31,7 +30,9 @@ export default function WatchlistManagement() {
|
||||
description="You’ll 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 />
|
||||
<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)}>
|
||||
Listing state changes (e.g. listing becomes inactive)
|
||||
@@ -41,7 +42,9 @@ export default function WatchlistManagement() {
|
||||
</Checkbox>
|
||||
|
||||
<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>
|
||||
|
||||
<NotificationAdapterMutator
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
border-radius: 24px;
|
||||
padding: 3rem;
|
||||
width: 90%;
|
||||
max-width: 420px;
|
||||
max-width: 500px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -4,16 +4,14 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { Toast } from '@douyinfe/semi-ui-19';
|
||||
import { Toast, Button } from '@douyinfe/semi-ui-19';
|
||||
import { IconPlus } from '@douyinfe/semi-icons';
|
||||
import UserTable from '../../components/table/UserTable';
|
||||
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 { xhrDelete } from '../../services/xhr';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import Headline from '../../components/headline/Headline.jsx';
|
||||
import './Users.less';
|
||||
|
||||
const Users = function Users() {
|
||||
@@ -28,14 +26,13 @@ const Users = function Users() {
|
||||
await actions.user.getUsers();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
init();
|
||||
}, []);
|
||||
|
||||
const onUserRemoval = async () => {
|
||||
try {
|
||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||
Toast.success('User successfully remove');
|
||||
Toast.success('User successfully removed');
|
||||
setUserIdToBeRemoved(null);
|
||||
await actions.jobsData.getJobs();
|
||||
await actions.user.getUsers();
|
||||
@@ -46,30 +43,22 @@ const Users = function Users() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="users">
|
||||
<Headline
|
||||
text="Users"
|
||||
actions={
|
||||
<Button type="primary" theme="solid" icon={<IconPlus />} onClick={() => navigate('/users/new')}>
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{!loading && (
|
||||
<React.Fragment>
|
||||
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="users__newButton"
|
||||
icon={<IconPlus />}
|
||||
onClick={() => navigate('/users/new')}
|
||||
>
|
||||
New User
|
||||
</Button>
|
||||
|
||||
<UserTable
|
||||
user={users}
|
||||
onUserEdit={(userId) => {
|
||||
navigate(`/users/edit/${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
|
||||
}}
|
||||
onUserEdit={(userId) => navigate(`/users/edit/${userId}`)}
|
||||
onUserRemoval={(userId) => setUserIdToBeRemoved(userId)}
|
||||
/>
|
||||
</React.Fragment>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@import '../../tokens.less';
|
||||
|
||||
.users {
|
||||
&__newButton {
|
||||
margin-top: 1rem !important;
|
||||
float: left;
|
||||
margin-bottom: 1rem !important;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { useActions } from '../../../services/state/store';
|
||||
import { Divider, Input, Switch, Button, Toast } from '@douyinfe/semi-ui-19';
|
||||
import './UserMutator.less';
|
||||
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 params = useParams();
|
||||
@@ -63,53 +64,70 @@ const UserMutator = function UserMutator() {
|
||||
};
|
||||
|
||||
return (
|
||||
<form className="userMutator">
|
||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
||||
<Input
|
||||
type="text"
|
||||
label="Username"
|
||||
maxLength={30}
|
||||
placeholder="Username"
|
||||
autoFocus
|
||||
width={6}
|
||||
value={username}
|
||||
onChange={(val) => setUsername(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
||||
<Input
|
||||
mode="password"
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
width={6}
|
||||
value={password}
|
||||
onChange={(val) => setPassword(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
||||
<Input
|
||||
mode="password"
|
||||
label="Retype password"
|
||||
placeholder="Retype password"
|
||||
width={6}
|
||||
value={password2}
|
||||
onChange={(val) => setPassword2(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="primary" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
<>
|
||||
<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">
|
||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
||||
<Input
|
||||
type="text"
|
||||
label="Username"
|
||||
maxLength={30}
|
||||
placeholder="Username"
|
||||
autoFocus
|
||||
width={6}
|
||||
value={username}
|
||||
onChange={(val) => setUsername(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
||||
<Input
|
||||
mode="password"
|
||||
label="Password"
|
||||
placeholder="Password"
|
||||
width={6}
|
||||
value={password}
|
||||
onChange={(val) => setPassword(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
||||
<Input
|
||||
mode="password"
|
||||
label="Retype password"
|
||||
placeholder="Retype password"
|
||||
width={6}
|
||||
value={password2}
|
||||
onChange={(val) => setPassword2(val)}
|
||||
/>
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||
</SegmentPart>
|
||||
<Divider margin="1rem" />
|
||||
<div className="userMutator__actions">
|
||||
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
@import '../../../tokens.less';
|
||||
|
||||
.userMutator {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: @space-2;
|
||||
margin-top: @space-2;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user