Compare commits

...
41 Commits
Author SHA1 Message Date
rajch_ales 17082f6d9e Use Playwright Firefox runtime 2026-08-05 21:25:44 +02:00
rajch_ales fe329cd19b Open source candidates with configured browser 2026-08-05 21:20:06 +02:00
rajch_ales d90e6c6dd8 Use configured browser for source windows 2026-08-05 21:15:19 +02:00
rajch_ales 6a1568a1dd Honor configured browser engine 2026-08-05 21:11:17 +02:00
rajch_ales 1f5ab5a8cc Add source popup reload action 2026-08-05 19:44:11 +02:00
rajch_ales e172b24474 Respect visible browser setting for source checks 2026-08-05 19:37:03 +02:00
rajch_ales bc96d50ecc Use local MariaDB as active database mode 2026-08-05 19:30:28 +02:00
rajch_ales b9c2e181eb Use configured Playwright browser engines 2026-08-05 19:21:14 +02:00
rajch_ales 8b2a74bcb3 Document authoritative database routing 2026-08-05 19:16:15 +02:00
rajch_ales 1ab09903fb Remove duplicate supplier field from product queries 2026-08-05 19:13:14 +02:00
rajch_ales f08699fad3 Name local database write permission explicitly 2026-08-05 19:07:37 +02:00
rajch_ales 2f3444adb8 Connect local write permissions to backend 2026-08-05 19:05:07 +02:00
rajch_ales e6acb21d65 Persist selected manufacturer across reloads 2026-08-05 18:58:05 +02:00
rajch_ales c58084cc86 Move database status below settings 2026-08-05 18:54:32 +02:00
rajch_ales f97d00ab7f Add database connection status indicator 2026-08-05 18:53:11 +02:00
rajch_ales f2fa405008 Center mapping status indicator with label 2026-08-05 18:44:29 +02:00
rajch_ales 85df4dc4cb Keep mapping chevron centered and downward 2026-08-05 18:42:55 +02:00
rajch_ales 90731319c8 Match mapping chevron and centered label 2026-08-05 18:40:26 +02:00
rajch_ales 0f9764cc49 Center mapping icon with label 2026-08-05 18:36:53 +02:00
rajch_ales 921890fbec Align mapping icon in status control 2026-08-05 18:35:13 +02:00
rajch_ales 657c82b4ba Pin mapping label to center 2026-08-05 18:33:51 +02:00
rajch_ales 071c37cb3a Fix mapping title centering 2026-08-05 18:32:03 +02:00
rajch_ales c440b6fc1b Center mapping control like selectors 2026-08-05 18:28:29 +02:00
rajch_ales 8d5521093e Keep mapping collapsed on reload 2026-08-05 18:26:19 +02:00
rajch_ales 3a707aa0ca Keep menu above database status in footer 2026-08-05 18:25:38 +02:00
rajch_ales 452ee68599 Place database status below menu 2026-08-05 18:24:44 +02:00
rajch_ales 1a8180c1f9 Move mapping below manufacturer selector 2026-08-05 18:22:33 +02:00
rajch_ales 1258d64aef Open mapping details below status control 2026-08-05 18:20:55 +02:00
rajch_ales 9d9789cd5b Add compact mapping health indicator 2026-08-05 18:17:14 +02:00
rajch_ales dab0859aa9 Align mapping status row 2026-08-05 18:09:55 +02:00
rajch_ales 7c7d5d2952 Remove background remover sidebar action 2026-08-05 18:07:19 +02:00
rajch_ales 0269a20187 Remove scraper sidebar action 2026-08-05 18:06:00 +02:00
rajch_ales d5b4f344dd Move mapping beside database status 2026-08-05 18:03:17 +02:00
rajch_ales c94b514647 Place mapping status with parser tools 2026-08-05 17:59:51 +02:00
rajch_ales 588c0bd9cc Keep parser actions always visible 2026-08-05 17:57:39 +02:00
rajch_ales e9aa381f45 Center parser drawer control group 2026-08-05 17:55:24 +02:00
rajch_ales 5882edb994 Audit buttons and selector controls 2026-08-05 17:53:44 +02:00
rajch_ales 310e1e239f Prevent duplicate selector labels 2026-08-05 17:49:58 +02:00
rajch_ales 4e1c3eccab Keep language selector icon visible 2026-08-05 17:47:01 +02:00
rajch_ales f463bd27e7 Align selector icons with centered labels 2026-08-05 17:45:22 +02:00
rajch_ales e68c5d2bc7 Hide native manufacturer control host 2026-08-05 17:42:41 +02:00
14 changed files with 417 additions and 149 deletions
+12 -1
View File
@@ -10,6 +10,7 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
- Local DB may later allow controlled writes, but writes must remain explicit, logged and protected by transaction/dry-run settings. - Local DB may later allow controlled writes, but writes must remain explicit, logged and protected by transaction/dry-run settings.
- Never print, commit or share endpoint tokens, usernames or passwords. - Never print, commit or share endpoint tokens, usernames or passwords.
- Never use Live DB as a fallback when Local DB is selected. If Local DB is unavailable, show an error and keep the UI running. - Never use Live DB as a fallback when Local DB is selected. If Local DB is unavailable, show an error and keep the UI running.
- The selected database mode is authoritative for every database call. When `Local 9bplus DB` is selected, manufacturers, languages, products, combinations, mapping, source configuration and all other catalog reads must use the local MariaDB connection; no endpoint or Live DB call is allowed. When `Live 9bplus DB` is selected, every catalog call must use the existing endpoint and remain read-only. Do not mix sources within one workflow.
- Keep the development server on port `3404` unless the user explicitly requests another port. - Keep the development server on port `3404` unless the user explicitly requests another port.
- Do not delete or revert user files or unrelated changes. - Do not delete or revert user files or unrelated changes.
@@ -51,6 +52,10 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
- Button icons must be placed before the label in DOM order, with the icon and label centered together as one group. Keep the control's chevron or disclosure arrow in its own fixed right-side slot so it does not shift the centered label. - Button icons must be placed before the label in DOM order, with the icon and label centered together as one group. Keep the control's chevron or disclosure arrow in its own fixed right-side slot so it does not shift the centered label.
- Use one shared icon size, gap, alignment, hover state and disabled state for equivalent controls. Do not mix icon-only, text-only and icon-after-text variants for the same control family. - Use one shared icon size, gap, alignment, hover state and disabled state for equivalent controls. Do not mix icon-only, text-only and icon-after-text variants for the same control family.
- Before finishing any UI change, inspect the rendered page with Playwright and verify that icons are visible, precede their labels, remain centered, and do not overlap or drift between controls. Update this file when a new reusable visual rule is discovered. - Before finishing any UI change, inspect the rendered page with Playwright and verify that icons are visible, precede their labels, remain centered, and do not overlap or drift between controls. Update this file when a new reusable visual rule is discovered.
- Custom select controls may use a native select as an interaction layer, but the native label must be visually transparent when a separate display label is rendered; never show the same control text twice.
- Runtime button labels must update the existing direct child label span instead of replacing `button.textContent`; state changes must never remove the button icon.
- Parser tools are always visible in the left panel. Do not hide, collapse or replace the parser action buttons with a toggle; the user must see the complete parser action set at all times.
- Mapping status and mapping source details belong beside the database connection status at the bottom of the sidebar. Style it as a shared sidebar section header with icon, centered status and a fixed right chevron; keep the existing mapping behavior.
## TypeScript workflow ## TypeScript workflow
@@ -64,6 +69,9 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
- Use Playwright for all supplier/manufacturer website scraping and browser page inspection. - Use Playwright for all supplier/manufacturer website scraping and browser page inspection.
- Do not implement new scraping with raw `fetch`, regex-only HTML parsing, Selenium or direct HTTP shortcuts. - Do not implement new scraping with raw `fetch`, regex-only HTML parsing, Selenium or direct HTTP shortcuts.
- Scrapers must use controlled browser navigation, selectors, explicit waits and bounded request counts. - Scrapers must use controlled browser navigation, selectors, explicit waits and bounded request counts.
- The configured browser engine for a source is authoritative. If a source says `firefox`, launch Firefox or fail loudly; do not silently fall back to Chromium, Edge or another browser.
- Visible source windows, URL health checks, source lookup and picture parsing must all use the same configured browser engine from that source's database setting.
- Source candidate `Open` actions in the UI must call the backend source opener with `manufacturerId`, `sourceKey` and URL. Do not use plain `target="_blank"` source links for mapped supplier/manufacturer sources because that ignores the configured browser engine.
- Keep scraping slow and observable, preserve source URL/status information, and handle blocked pages or missing selectors as explicit errors. - Keep scraping slow and observable, preserve source URL/status information, and handle blocked pages or missing selectors as explicit errors.
- Supplier data remains a local draft until the user explicitly confirms an Apply / Save action. - Supplier data remains a local draft until the user explicitly confirms an Apply / Save action.
@@ -73,7 +81,7 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
- Open `http://127.0.0.1:3404/`. - Open `http://127.0.0.1:3404/`.
- The current server is `src/server.ts`, run through `tsx`. - The current server is `src/server.ts`, run through `tsx`.
- Configuration is loaded from `config/local.json`, merged over `config/local.example.json`. - Configuration is loaded from `config/local.json`, merged over `config/local.example.json`.
- `.env` exists for local MariaDB notes, but the application does not yet load `.env` automatically. Do not assume `.env` is active until configuration loading is explicitly wired to it. - `.env` is loaded automatically by `src/config.ts` for local MariaDB settings. Never expose its values. The explicit local write capability flag is `DB_LOCAL_ALLOW_WRITES`; it never enables Live DB writes.
## Database architecture ## Database architecture
@@ -113,8 +121,10 @@ There are three selectable data sources:
- The UI has `Local 9bplus DB`, `Live 9bplus DB` and `Local scrape DB` settings. - The UI has `Local 9bplus DB`, `Live 9bplus DB` and `Local scrape DB` settings.
- The UI sends the selected mode using the `X-Database-Mode` request header. - The UI sends the selected mode using the `X-Database-Mode` request header.
- The backend must resolve that header before every API database operation and route the complete request through the selected source. A Local DB request must use `queryMariaDb`; a Live DB request must use `queryEndpoint`; there is no silent fallback or mixed-source response.
- Backend data API routes reject a Local DB request when MariaDB is not configured instead of silently using the endpoint. - Backend data API routes reject a Local DB request when MariaDB is not configured instead of silently using the endpoint.
- Backend data API routes reject a Live DB request when the project is configured only for MariaDB. - Backend data API routes reject a Live DB request when the project is configured only for MariaDB.
- Mapping endpoints follow the same rule as product endpoints and must read mapping tables from the selected database.
- Static HTML/CSS/JS files must remain available even when a database is not configured. - Static HTML/CSS/JS files must remain available even when a database is not configured.
- The top status shows the selected source, connection state, permissions and execution mode. - The top status shows the selected source, connection state, permissions and execution mode.
- Selecting Live 9bplus DB or Local scrape DB disables catalog write permissions in the UI. - Selecting Live 9bplus DB or Local scrape DB disables catalog write permissions in the UI.
@@ -140,6 +150,7 @@ There are three selectable data sources:
- `Get product info` remains a separate action. - `Get product info` remains a separate action.
- EAN scan input searches on Enter. - EAN scan input searches on Enter.
- `Find sources` searches once and caches candidates for the current product; a second click reopens cached results. - `Find sources` searches once and caches candidates for the current product; a second click reopens cached results.
- The source-candidates popup must provide a `Reload` action beside `Close`; `Reload` bypasses only the current product's cached source result and re-runs the source lookup using the current local mapping configuration. It must not switch database modes or write to Live DB.
- Source candidates show how they were found, including EAN/reference/name and color context. - Source candidates show how they were found, including EAN/reference/name and color context.
- Each source row has its own `Get pictures` action. - Each source row has its own `Get pictures` action.
- Product source cache is cleared when a new product loads. - Product source cache is cleared when a new product loads.
+1 -1
View File
@@ -16,7 +16,7 @@
"database": { "database": {
"driver": "endpoint", "driver": "endpoint",
"mode": "local", "mode": "local",
"allowWrites": false, "allowLocalWrites": false,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 3306, "port": 3306,
"name": "9bplus", "name": "9bplus",
+106 -35
View File
@@ -8,6 +8,7 @@ const manufacturerSearch = document.querySelector("#manufacturerSearch");
const manufacturerOptions = document.querySelector("#manufacturerOptions"); const manufacturerOptions = document.querySelector("#manufacturerOptions");
const manufacturerValue = document.querySelector("#manufacturerValue"); const manufacturerValue = document.querySelector("#manufacturerValue");
const languageSelect = document.querySelector("#languageSelect"); const languageSelect = document.querySelector("#languageSelect");
const languageValue = document.querySelector("#languageValue");
const recordIdElement = document.querySelector("#recordId"); const recordIdElement = document.querySelector("#recordId");
const loadProductButton = document.querySelector("#loadProductButton"); const loadProductButton = document.querySelector("#loadProductButton");
const previousProductButton = document.querySelector("#previousProductButton"); const previousProductButton = document.querySelector("#previousProductButton");
@@ -21,12 +22,11 @@ const suggestedTitle = document.querySelector("#suggestedTitle");
const suggestedList = document.querySelector("#suggestedList"); const suggestedList = document.querySelector("#suggestedList");
const suggestedCloseButton = document.querySelector("#suggestedCloseButton"); const suggestedCloseButton = document.querySelector("#suggestedCloseButton");
const mappingState = document.querySelector("#mappingState"); const mappingState = document.querySelector("#mappingState");
const mappingStatusText = document.querySelector("#mappingStatusText");
const mappingList = document.querySelector("#mappingList"); const mappingList = document.querySelector("#mappingList");
const mappingDetails = document.querySelector("#mappingDetails"); const mappingDetails = document.querySelector("#mappingDetails");
const findSourcesButton = document.querySelector("#findSourcesButton"); const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton"); const getPicturesButton = document.querySelector("#getPicturesButton");
const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
const parserDrawer = document.querySelector("#parserDrawer");
const sidebarDrawer = document.querySelector("#sidebarDrawer"); const sidebarDrawer = document.querySelector("#sidebarDrawer");
const sidebarOpenButton = document.querySelector("#sidebarOpenButton"); const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
const sidebarCloseButton = document.querySelector("#sidebarCloseButton"); const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
@@ -34,6 +34,7 @@ const sidebarOverlay = document.querySelector("#sidebarOverlay");
const sourceModal = document.querySelector("#sourceModal"); const sourceModal = document.querySelector("#sourceModal");
const sourceTitle = document.querySelector("#sourceTitle"); const sourceTitle = document.querySelector("#sourceTitle");
const sourceList = document.querySelector("#sourceList"); const sourceList = document.querySelector("#sourceList");
const sourceReloadButton = document.querySelector("#sourceReloadButton");
const sourceCloseButton = document.querySelector("#sourceCloseButton"); const sourceCloseButton = document.querySelector("#sourceCloseButton");
const settingsButton = document.querySelector("#settingsButton"); const settingsButton = document.querySelector("#settingsButton");
const themeToggle = document.querySelector("#themeToggle"); const themeToggle = document.querySelector("#themeToggle");
@@ -44,6 +45,7 @@ const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]
const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle"); const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle");
const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]'); const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]');
const databaseStatus = document.querySelector("#databaseStatus"); const databaseStatus = document.querySelector("#databaseStatus");
const databaseStateDot = document.querySelector("#databaseStateDot");
const localDbFields = document.querySelector("#localDbFields"); const localDbFields = document.querySelector("#localDbFields");
const localDbInputs = document.querySelectorAll("#localDbFields input"); const localDbInputs = document.querySelectorAll("#localDbFields input");
const testLocalDbButton = document.querySelector("#testLocalDbButton"); const testLocalDbButton = document.querySelector("#testLocalDbButton");
@@ -61,9 +63,16 @@ window.fetch = (input, init = {}) => {
return nativeFetch(input, init); return nativeFetch(input, init);
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
const localWritesEnabled = selectedMode === "local" && [
permissions.allowInsertToggle,
permissions.allowUpdateToggle,
permissions.allowDeleteToggle,
].some(Boolean);
// Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus. // Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus.
headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local"); headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local");
headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0"); headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0");
headers.set("X-Local-Write-Access", localWritesEnabled ? "1" : "0");
return nativeFetch(input, { ...init, headers }); return nativeFetch(input, { ...init, headers });
}; };
let currentPosition = 1; let currentPosition = 1;
@@ -74,6 +83,7 @@ let loadRequestId = 0;
let applicationMode = "dry-run"; let applicationMode = "dry-run";
let databaseConnectionState = "unknown"; let databaseConnectionState = "unknown";
const localDraftKey = "catalog-maker:current-product-draft:v2"; const localDraftKey = "catalog-maker:current-product-draft:v2";
const selectedManufacturerKey = "catalog-maker:selected-manufacturer";
let localDraftProductKey = ""; let localDraftProductKey = "";
const fieldNames = [ const fieldNames = [
"id_manufacturer", "id_manufacturer",
@@ -143,7 +153,14 @@ renderTableHeader();
setTableWidth(); setTableWidth();
await loadManufacturers(); await loadManufacturers();
await loadLanguages(); await loadLanguages();
languageSelect.addEventListener("change", syncLanguagePickerLabel);
manufacturerSelect.addEventListener("change", () => { manufacturerSelect.addEventListener("change", () => {
if (manufacturerSelect.value) {
localStorage.setItem(selectedManufacturerKey, manufacturerSelect.value);
}
else {
localStorage.removeItem(selectedManufacturerKey);
}
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
closeManufacturerPicker(); closeManufacturerPicker();
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
@@ -169,8 +186,8 @@ loadProductButton.disabled = true;
loadProductButton.title = "Load existing product attribute info."; loadProductButton.title = "Load existing product attribute info.";
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct); loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
sourceReloadButton.addEventListener("click", () => findSourcesForCurrentProduct(true));
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel); secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace); mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
sidebarOpenButton.addEventListener("click", openSidebar); sidebarOpenButton.addEventListener("click", openSidebar);
@@ -288,15 +305,29 @@ async function loadManufacturers() {
} }
if (data.message) if (data.message)
manufacturerSelect.append(createOption("", data.message)); manufacturerSelect.append(createOption("", data.message));
restoreSelectedManufacturer();
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
renderManufacturerOptions(); renderManufacturerOptions();
} }
catch (error) { catch (error) {
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
restoreSelectedManufacturer();
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
renderManufacturerOptions(); renderManufacturerOptions();
} }
} }
function restoreSelectedManufacturer() {
const savedManufacturerId = localStorage.getItem(selectedManufacturerKey);
if (!savedManufacturerId)
return;
const savedOption = [...manufacturerSelect.options].find((option) => option.value === savedManufacturerId);
if (savedOption) {
manufacturerSelect.value = savedManufacturerId;
}
else {
localStorage.removeItem(selectedManufacturerKey);
}
}
function toggleManufacturerPicker() { function toggleManufacturerPicker() {
if (manufacturerMenu.hidden) { if (manufacturerMenu.hidden) {
manufacturerMenu.hidden = false; manufacturerMenu.hidden = false;
@@ -349,6 +380,15 @@ function createOption(value, label) {
option.textContent = label; option.textContent = label;
return option; return option;
} }
function setButtonLabel(button, label) {
const labelElement = button.querySelector(":scope > span");
if (labelElement) {
labelElement.textContent = label;
}
else {
button.textContent = label;
}
}
async function loadLanguages() { async function loadLanguages() {
languageSelect.replaceChildren(createOption("", "Select language")); languageSelect.replaceChildren(createOption("", "Select language"));
try { try {
@@ -366,6 +406,11 @@ async function loadLanguages() {
catch (error) { catch (error) {
languageSelect.append(createOption("", error.message || "Local DB is not configured.")); languageSelect.append(createOption("", error.message || "Local DB is not configured."));
} }
syncLanguagePickerLabel();
}
function syncLanguagePickerLabel() {
const option = languageSelect.selectedOptions[0];
languageValue.textContent = option?.textContent || "Select language";
} }
async function loadMappingSources() { async function loadMappingSources() {
const manufacturerId = manufacturerSelect.value; const manufacturerId = manufacturerSelect.value;
@@ -373,8 +418,7 @@ async function loadMappingSources() {
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." }); renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
return; return;
} }
mappingState.textContent = "Loading mapping..."; setMappingStatus("Loading mapping...", "is-loading");
mappingState.className = "mapping-state";
mappingList.replaceChildren(); mappingList.replaceChildren();
try { try {
const params = new URLSearchParams({ manufacturerId, check: "1" }); const params = new URLSearchParams({ manufacturerId, check: "1" });
@@ -396,21 +440,20 @@ async function loadMappingSources() {
function renderMappingSources(data) { function renderMappingSources(data) {
const items = data.items || []; const items = data.items || [];
if (!data.mapped || !items.length) { if (!data.mapped || !items.length) {
mappingState.textContent = data.message || "Mapping missing"; setMappingStatus(data.message || "Mapping missing", "is-missing");
mappingState.className = "mapping-state is-missing";
mappingList.replaceChildren(); mappingList.replaceChildren();
mappingDetails.open = true; mappingDetails.open = false;
return; return;
} }
const enabledCount = items.filter((item) => item.enabled).length; const enabledCount = items.filter((item) => item.enabled).length;
const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length;
const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; const testedCount = items.filter((item) => item.enabled && item.health === "ok").length;
const ready = enabledCount > 0 && failedCount === 0; const ready = enabledCount > 0 && failedCount === 0;
mappingState.textContent = ready const statusText = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})` ? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${testedCount}/${enabledCount})`; : data.message || `Sources problem (${testedCount}/${enabledCount})`;
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing"; setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing");
mappingDetails.open = !ready || failedCount > 0; mappingDetails.open = false;
mappingList.replaceChildren(...items.map((item) => { mappingList.replaceChildren(...items.map((item) => {
const row = document.createElement("div"); const row = document.createElement("div");
row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`; row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`;
@@ -424,6 +467,12 @@ function renderMappingSources(data) {
return row; return row;
})); }));
} }
function setMappingStatus(text, stateClass) {
mappingState.textContent = "";
mappingState.className = `mapping-state-dot ${stateClass}`;
mappingState.setAttribute("aria-label", text);
mappingStatusText.textContent = text;
}
function getMappingHealthText(item) { function getMappingHealthText(item) {
if (!item.enabled) if (!item.enabled)
return "off"; return "off";
@@ -599,13 +648,13 @@ function closeSuggestedProducts() {
suggestedModal.hidden = true; suggestedModal.hidden = true;
suggestedList.replaceChildren(); suggestedList.replaceChildren();
} }
async function findSourcesForCurrentProduct() { async function findSourcesForCurrentProduct(forceRefresh = false) {
if (!manufacturerSelect.value || !currentProduct) { if (!manufacturerSelect.value || !currentProduct) {
setTableMessage("Select manufacturer and product first."); setTableMessage("Select manufacturer and product first.");
return; return;
} }
const savedDraft = readLocalDraft(); const savedDraft = readLocalDraft();
if (savedDraft?.sources) { if (savedDraft?.sources && !forceRefresh) {
renderSourceCandidates(savedDraft.sources.items || []); renderSourceCandidates(savedDraft.sources.items || []);
updateFindSourcesButtonState(); updateFindSourcesButtonState();
sourceTitle.textContent = "Source candidates (cached)"; sourceTitle.textContent = "Source candidates (cached)";
@@ -614,7 +663,8 @@ async function findSourcesForCurrentProduct() {
} }
const supplierColorGroup = getFirstSupplierColorGroup(); const supplierColorGroup = getFirstSupplierColorGroup();
findSourcesButton.disabled = true; findSourcesButton.disabled = true;
findSourcesButton.textContent = "Finding sources..."; sourceReloadButton.disabled = true;
setButtonLabel(findSourcesButton, "Finding sources...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value, manufacturerId: manufacturerSelect.value,
@@ -642,6 +692,7 @@ async function findSourcesForCurrentProduct() {
setTableMessage(error.message || "Source search failed."); setTableMessage(error.message || "Source search failed.");
} }
finally { finally {
sourceReloadButton.disabled = false;
updateFindSourcesButtonState(); updateFindSourcesButtonState();
} }
} }
@@ -660,7 +711,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
} }
const supplierColorGroup = getFirstSupplierColorGroup(); const supplierColorGroup = getFirstSupplierColorGroup();
getPicturesButton.disabled = true; getPicturesButton.disabled = true;
getPicturesButton.textContent = "Loading pictures..."; setButtonLabel(getPicturesButton, "Loading pictures...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value, manufacturerId: manufacturerSelect.value,
@@ -688,7 +739,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
} }
finally { finally {
getPicturesButton.disabled = false; getPicturesButton.disabled = false;
getPicturesButton.textContent = "Get pictures"; setButtonLabel(getPicturesButton, "Get pictures");
updateNavigationState(); updateNavigationState();
} }
} }
@@ -740,8 +791,8 @@ function renderSourceCandidates(items) {
actions.append(status, pictures); actions.append(status, pictures);
header.append(name, actions); header.append(name, actions);
const rows = item.candidates.length const rows = item.candidates.length
? item.candidates.map(renderSourceCandidateRow) ? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source))
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; : [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)];
section.append(header, ...rows); section.append(header, ...rows);
return section; return section;
})); }));
@@ -813,30 +864,54 @@ function clearLocalDraft() {
} }
function updateFindSourcesButtonState() { function updateFindSourcesButtonState() {
const hasSources = Boolean(readLocalDraft()?.sources); const hasSources = Boolean(readLocalDraft()?.sources);
findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources"; setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources");
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
} }
function renderSourceCandidateRow(candidate) { function renderSourceCandidateRow(candidate, source) {
const row = document.createElement("a"); const row = document.createElement("div");
row.className = "source-row"; row.className = "source-row";
row.href = candidate.url;
row.target = "_blank";
row.rel = "noreferrer";
const title = document.createElement("span"); const title = document.createElement("span");
title.className = "source-row-title"; title.className = "source-row-title";
title.textContent = formatValue(candidate.title || candidate.url); title.textContent = formatValue(candidate.title || candidate.url);
title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url);
const action = document.createElement("span"); const action = document.createElement("button");
action.type = "button";
action.className = "source-row-action"; action.className = "source-row-action";
action.textContent = "Open"; action.textContent = "Open";
action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action));
row.append(title, action); row.append(title, action);
return row; return row;
} }
function renderManualSearchRow(url, label) { async function openSourceCandidate(url, source, button) {
if (!url || !source?.key || !manufacturerSelect.value)
return;
const previousText = button.textContent;
button.disabled = true;
button.textContent = "Opening...";
try {
const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value,
sourceKey: source.key,
url,
});
const response = await fetch(`/api/catalog-maker/open-source?${params.toString()}`);
const data = await response.json();
if (!response.ok || data.error)
throw new Error(data.error || "Source browser could not be opened.");
}
catch (error) {
alert(error.message || "Source browser could not be opened.");
}
finally {
button.disabled = false;
button.textContent = previousText;
}
}
function renderManualSearchRow(url, label, source) {
return renderSourceCandidateRow({ return renderSourceCandidateRow({
title: label, title: label,
url, url,
}); }, source);
} }
function closeSourceCandidates() { function closeSourceCandidates() {
sourceModal.hidden = true; sourceModal.hidden = true;
@@ -845,12 +920,6 @@ function closeSourceCandidates() {
function openSettings() { function openSettings() {
settingsModal.hidden = false; settingsModal.hidden = false;
} }
function toggleParserDrawer() {
const isOpen = !parserDrawer.hidden;
parserDrawer.hidden = isOpen;
parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen));
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
}
function syncSidebarForViewport() { function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed)
openSidebar(); openSidebar();
@@ -1024,6 +1093,8 @@ function refreshDatabaseStatusLabel() {
: (transactionMode === "dry-run" ? "dry-run" : "transaction"); : (transactionMode === "dry-run" ? "dry-run" : "transaction");
databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`;
databaseStatus.classList.toggle("database-error", connectionError); databaseStatus.classList.toggle("database-error", connectionError);
databaseStateDot.className = `database-state-dot ${databaseConnectionState === "unknown" ? "is-pending" : connectionError ? "is-error" : "is-ready"}`;
databaseStateDot.setAttribute("aria-label", connectionError ? "Database disconnected" : databaseConnectionState === "unknown" ? "Database connection pending" : "Database connected");
} }
function renderDatabaseModeNote(mode) { function renderDatabaseModeNote(mode) {
databaseModeNote.textContent = mode === "live" databaseModeNote.textContent = mode === "live"
@@ -1205,7 +1276,7 @@ async function loadProductInfoForCurrentProduct() {
return; return;
} }
loadProductButton.disabled = true; loadProductButton.disabled = true;
loadProductButton.textContent = "Loading info..."; setButtonLabel(loadProductButton, "Loading info...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
productId: currentProduct.idProduct, productId: currentProduct.idProduct,
@@ -1228,7 +1299,7 @@ async function loadProductInfoForCurrentProduct() {
setTableMessage(error.message); setTableMessage(error.message);
} }
finally { finally {
loadProductButton.textContent = "Get product info - 00"; setButtonLabel(loadProductButton, "Get product info - 00");
updateNavigationState(); updateNavigationState();
} }
} }
+107 -36
View File
@@ -8,6 +8,7 @@ const manufacturerSearch = document.querySelector("#manufacturerSearch");
const manufacturerOptions = document.querySelector("#manufacturerOptions"); const manufacturerOptions = document.querySelector("#manufacturerOptions");
const manufacturerValue = document.querySelector("#manufacturerValue"); const manufacturerValue = document.querySelector("#manufacturerValue");
const languageSelect = document.querySelector("#languageSelect"); const languageSelect = document.querySelector("#languageSelect");
const languageValue = document.querySelector("#languageValue");
const recordIdElement = document.querySelector("#recordId"); const recordIdElement = document.querySelector("#recordId");
const loadProductButton = document.querySelector("#loadProductButton"); const loadProductButton = document.querySelector("#loadProductButton");
const previousProductButton = document.querySelector("#previousProductButton"); const previousProductButton = document.querySelector("#previousProductButton");
@@ -21,12 +22,11 @@ const suggestedTitle = document.querySelector("#suggestedTitle");
const suggestedList = document.querySelector("#suggestedList"); const suggestedList = document.querySelector("#suggestedList");
const suggestedCloseButton = document.querySelector("#suggestedCloseButton"); const suggestedCloseButton = document.querySelector("#suggestedCloseButton");
const mappingState = document.querySelector("#mappingState"); const mappingState = document.querySelector("#mappingState");
const mappingStatusText = document.querySelector("#mappingStatusText");
const mappingList = document.querySelector("#mappingList"); const mappingList = document.querySelector("#mappingList");
const mappingDetails = document.querySelector("#mappingDetails"); const mappingDetails = document.querySelector("#mappingDetails");
const findSourcesButton = document.querySelector("#findSourcesButton"); const findSourcesButton = document.querySelector("#findSourcesButton");
const getPicturesButton = document.querySelector("#getPicturesButton"); const getPicturesButton = document.querySelector("#getPicturesButton");
const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
const parserDrawer = document.querySelector("#parserDrawer");
const sidebarDrawer = document.querySelector("#sidebarDrawer"); const sidebarDrawer = document.querySelector("#sidebarDrawer");
const sidebarOpenButton = document.querySelector("#sidebarOpenButton"); const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
const sidebarCloseButton = document.querySelector("#sidebarCloseButton"); const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
@@ -34,6 +34,7 @@ const sidebarOverlay = document.querySelector("#sidebarOverlay");
const sourceModal = document.querySelector("#sourceModal"); const sourceModal = document.querySelector("#sourceModal");
const sourceTitle = document.querySelector("#sourceTitle"); const sourceTitle = document.querySelector("#sourceTitle");
const sourceList = document.querySelector("#sourceList"); const sourceList = document.querySelector("#sourceList");
const sourceReloadButton = document.querySelector("#sourceReloadButton");
const sourceCloseButton = document.querySelector("#sourceCloseButton"); const sourceCloseButton = document.querySelector("#sourceCloseButton");
const settingsButton = document.querySelector("#settingsButton"); const settingsButton = document.querySelector("#settingsButton");
const themeToggle = document.querySelector("#themeToggle"); const themeToggle = document.querySelector("#themeToggle");
@@ -44,6 +45,7 @@ const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]
const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle"); const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle");
const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]'); const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]');
const databaseStatus = document.querySelector("#databaseStatus"); const databaseStatus = document.querySelector("#databaseStatus");
const databaseStateDot = document.querySelector("#databaseStateDot");
const localDbFields = document.querySelector("#localDbFields"); const localDbFields = document.querySelector("#localDbFields");
const localDbInputs = document.querySelectorAll("#localDbFields input"); const localDbInputs = document.querySelectorAll("#localDbFields input");
const testLocalDbButton = document.querySelector("#testLocalDbButton"); const testLocalDbButton = document.querySelector("#testLocalDbButton");
@@ -62,9 +64,16 @@ window.fetch = (input, init = {}) => {
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined)); const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local"; const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
const localWritesEnabled = selectedMode === "local" && [
permissions.allowInsertToggle,
permissions.allowUpdateToggle,
permissions.allowDeleteToggle,
].some(Boolean);
// Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus. // Scrape storage is separate; existing catalog endpoints continue reading Local 9bplus.
headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local"); headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local");
headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0"); headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0");
headers.set("X-Local-Write-Access", localWritesEnabled ? "1" : "0");
return nativeFetch(input, { ...init, headers }); return nativeFetch(input, { ...init, headers });
}; };
@@ -76,6 +85,7 @@ let loadRequestId = 0;
let applicationMode = "dry-run"; let applicationMode = "dry-run";
let databaseConnectionState = "unknown"; let databaseConnectionState = "unknown";
const localDraftKey = "catalog-maker:current-product-draft:v2"; const localDraftKey = "catalog-maker:current-product-draft:v2";
const selectedManufacturerKey = "catalog-maker:selected-manufacturer";
let localDraftProductKey = ""; let localDraftProductKey = "";
const fieldNames = [ const fieldNames = [
@@ -149,8 +159,14 @@ renderTableHeader();
setTableWidth(); setTableWidth();
await loadManufacturers(); await loadManufacturers();
await loadLanguages(); await loadLanguages();
languageSelect.addEventListener("change", syncLanguagePickerLabel);
manufacturerSelect.addEventListener("change", () => { manufacturerSelect.addEventListener("change", () => {
if (manufacturerSelect.value) {
localStorage.setItem(selectedManufacturerKey, manufacturerSelect.value);
} else {
localStorage.removeItem(selectedManufacturerKey);
}
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
closeManufacturerPicker(); closeManufacturerPicker();
if (manufacturerSelect.value) { if (manufacturerSelect.value) {
@@ -175,8 +191,8 @@ loadProductButton.disabled = true;
loadProductButton.title = "Load existing product attribute info."; loadProductButton.title = "Load existing product attribute info.";
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct); loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct); findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
sourceReloadButton.addEventListener("click", () => findSourcesForCurrentProduct(true));
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct); getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel); secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace); mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
sidebarOpenButton.addEventListener("click", openSidebar); sidebarOpenButton.addEventListener("click", openSidebar);
@@ -301,15 +317,31 @@ async function loadManufacturers() {
manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name)); manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name));
} }
if (data.message) manufacturerSelect.append(createOption("", data.message)); if (data.message) manufacturerSelect.append(createOption("", data.message));
restoreSelectedManufacturer();
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
renderManufacturerOptions(); renderManufacturerOptions();
} catch (error) { } catch (error) {
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured.")); manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
restoreSelectedManufacturer();
syncManufacturerPickerLabel(); syncManufacturerPickerLabel();
renderManufacturerOptions(); renderManufacturerOptions();
} }
} }
function restoreSelectedManufacturer() {
const savedManufacturerId = localStorage.getItem(selectedManufacturerKey);
if (!savedManufacturerId) return;
const savedOption = [...manufacturerSelect.options].find(
(option) => option.value === savedManufacturerId,
);
if (savedOption) {
manufacturerSelect.value = savedManufacturerId;
} else {
localStorage.removeItem(selectedManufacturerKey);
}
}
function toggleManufacturerPicker() { function toggleManufacturerPicker() {
if (manufacturerMenu.hidden) { if (manufacturerMenu.hidden) {
manufacturerMenu.hidden = false; manufacturerMenu.hidden = false;
@@ -369,6 +401,15 @@ function createOption(value, label) {
return option; return option;
} }
function setButtonLabel(button, label) {
const labelElement = button.querySelector(":scope > span");
if (labelElement) {
labelElement.textContent = label;
} else {
button.textContent = label;
}
}
async function loadLanguages() { async function loadLanguages() {
languageSelect.replaceChildren(createOption("", "Select language")); languageSelect.replaceChildren(createOption("", "Select language"));
try { try {
@@ -384,6 +425,12 @@ async function loadLanguages() {
} catch (error) { } catch (error) {
languageSelect.append(createOption("", error.message || "Local DB is not configured.")); languageSelect.append(createOption("", error.message || "Local DB is not configured."));
} }
syncLanguagePickerLabel();
}
function syncLanguagePickerLabel() {
const option = languageSelect.selectedOptions[0];
languageValue.textContent = option?.textContent || "Select language";
} }
async function loadMappingSources() { async function loadMappingSources() {
@@ -393,8 +440,7 @@ async function loadMappingSources() {
return; return;
} }
mappingState.textContent = "Loading mapping..."; setMappingStatus("Loading mapping...", "is-loading");
mappingState.className = "mapping-state";
mappingList.replaceChildren(); mappingList.replaceChildren();
try { try {
@@ -420,10 +466,9 @@ function renderMappingSources(data) {
const items = data.items || []; const items = data.items || [];
if (!data.mapped || !items.length) { if (!data.mapped || !items.length) {
mappingState.textContent = data.message || "Mapping missing"; setMappingStatus(data.message || "Mapping missing", "is-missing");
mappingState.className = "mapping-state is-missing";
mappingList.replaceChildren(); mappingList.replaceChildren();
mappingDetails.open = true; mappingDetails.open = false;
return; return;
} }
@@ -431,11 +476,11 @@ function renderMappingSources(data) {
const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length; const failedCount = items.filter((item) => item.enabled && item.health && item.health !== "ok").length;
const testedCount = items.filter((item) => item.enabled && item.health === "ok").length; const testedCount = items.filter((item) => item.enabled && item.health === "ok").length;
const ready = enabledCount > 0 && failedCount === 0; const ready = enabledCount > 0 && failedCount === 0;
mappingState.textContent = ready const statusText = ready
? `Sources OK ✓ (${testedCount}/${enabledCount})` ? `Sources OK ✓ (${testedCount}/${enabledCount})`
: data.message || `Sources problem (${testedCount}/${enabledCount})`; : data.message || `Sources problem (${testedCount}/${enabledCount})`;
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing"; setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing");
mappingDetails.open = !ready || failedCount > 0; mappingDetails.open = false;
mappingList.replaceChildren( mappingList.replaceChildren(
...items.map((item) => { ...items.map((item) => {
@@ -456,6 +501,13 @@ function renderMappingSources(data) {
); );
} }
function setMappingStatus(text, stateClass) {
mappingState.textContent = "";
mappingState.className = `mapping-state-dot ${stateClass}`;
mappingState.setAttribute("aria-label", text);
mappingStatusText.textContent = text;
}
function getMappingHealthText(item) { function getMappingHealthText(item) {
if (!item.enabled) return "off"; if (!item.enabled) return "off";
if (item.health === "ok") return `${item.httpStatus || 200} OK`; if (item.health === "ok") return `${item.httpStatus || 200} OK`;
@@ -650,14 +702,14 @@ function closeSuggestedProducts() {
suggestedList.replaceChildren(); suggestedList.replaceChildren();
} }
async function findSourcesForCurrentProduct() { async function findSourcesForCurrentProduct(forceRefresh = false) {
if (!manufacturerSelect.value || !currentProduct) { if (!manufacturerSelect.value || !currentProduct) {
setTableMessage("Select manufacturer and product first."); setTableMessage("Select manufacturer and product first.");
return; return;
} }
const savedDraft = readLocalDraft(); const savedDraft = readLocalDraft();
if (savedDraft?.sources) { if (savedDraft?.sources && !forceRefresh) {
renderSourceCandidates(savedDraft.sources.items || []); renderSourceCandidates(savedDraft.sources.items || []);
updateFindSourcesButtonState(); updateFindSourcesButtonState();
sourceTitle.textContent = "Source candidates (cached)"; sourceTitle.textContent = "Source candidates (cached)";
@@ -667,7 +719,8 @@ async function findSourcesForCurrentProduct() {
const supplierColorGroup = getFirstSupplierColorGroup(); const supplierColorGroup = getFirstSupplierColorGroup();
findSourcesButton.disabled = true; findSourcesButton.disabled = true;
findSourcesButton.textContent = "Finding sources..."; sourceReloadButton.disabled = true;
setButtonLabel(findSourcesButton, "Finding sources...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -696,6 +749,7 @@ async function findSourcesForCurrentProduct() {
} catch (error) { } catch (error) {
setTableMessage(error.message || "Source search failed."); setTableMessage(error.message || "Source search failed.");
} finally { } finally {
sourceReloadButton.disabled = false;
updateFindSourcesButtonState(); updateFindSourcesButtonState();
} }
} }
@@ -717,7 +771,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
const supplierColorGroup = getFirstSupplierColorGroup(); const supplierColorGroup = getFirstSupplierColorGroup();
getPicturesButton.disabled = true; getPicturesButton.disabled = true;
getPicturesButton.textContent = "Loading pictures..."; setButtonLabel(getPicturesButton, "Loading pictures...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -744,7 +798,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
setTableMessage(error.message || "Picture lookup failed."); setTableMessage(error.message || "Picture lookup failed.");
} finally { } finally {
getPicturesButton.disabled = false; getPicturesButton.disabled = false;
getPicturesButton.textContent = "Get pictures"; setButtonLabel(getPicturesButton, "Get pictures");
updateNavigationState(); updateNavigationState();
} }
} }
@@ -810,8 +864,8 @@ function renderSourceCandidates(items) {
header.append(name, actions); header.append(name, actions);
const rows = item.candidates.length const rows = item.candidates.length
? item.candidates.map(renderSourceCandidateRow) ? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source))
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")]; : [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)];
section.append(header, ...rows); section.append(header, ...rows);
return section; return section;
@@ -892,35 +946,57 @@ function clearLocalDraft() {
function updateFindSourcesButtonState() { function updateFindSourcesButtonState() {
const hasSources = Boolean(readLocalDraft()?.sources); const hasSources = Boolean(readLocalDraft()?.sources);
findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources"; setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources");
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct; findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
} }
function renderSourceCandidateRow(candidate) { function renderSourceCandidateRow(candidate, source) {
const row = document.createElement("a"); const row = document.createElement("div");
row.className = "source-row"; row.className = "source-row";
row.href = candidate.url;
row.target = "_blank";
row.rel = "noreferrer";
const title = document.createElement("span"); const title = document.createElement("span");
title.className = "source-row-title"; title.className = "source-row-title";
title.textContent = formatValue(candidate.title || candidate.url); title.textContent = formatValue(candidate.title || candidate.url);
title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url); title.title = formatValue(candidate.rawTitle || candidate.title || candidate.url);
const action = document.createElement("span"); const action = document.createElement("button");
action.type = "button";
action.className = "source-row-action"; action.className = "source-row-action";
action.textContent = "Open"; action.textContent = "Open";
action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action));
row.append(title, action); row.append(title, action);
return row; return row;
} }
function renderManualSearchRow(url, label) { async function openSourceCandidate(url, source, button) {
if (!url || !source?.key || !manufacturerSelect.value) return;
const previousText = button.textContent;
button.disabled = true;
button.textContent = "Opening...";
try {
const params = new URLSearchParams({
manufacturerId: manufacturerSelect.value,
sourceKey: source.key,
url,
});
const response = await fetch(`/api/catalog-maker/open-source?${params.toString()}`);
const data = await response.json();
if (!response.ok || data.error) throw new Error(data.error || "Source browser could not be opened.");
} catch (error) {
alert(error.message || "Source browser could not be opened.");
} finally {
button.disabled = false;
button.textContent = previousText;
}
}
function renderManualSearchRow(url, label, source) {
return renderSourceCandidateRow({ return renderSourceCandidateRow({
title: label, title: label,
url, url,
}); }, source);
} }
function closeSourceCandidates() { function closeSourceCandidates() {
@@ -932,13 +1008,6 @@ function openSettings() {
settingsModal.hidden = false; settingsModal.hidden = false;
} }
function toggleParserDrawer() {
const isOpen = !parserDrawer.hidden;
parserDrawer.hidden = isOpen;
parserDrawerToggle.setAttribute("aria-expanded", String(!isOpen));
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
}
function syncSidebarForViewport() { function syncSidebarForViewport() {
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) openSidebar(); if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) openSidebar();
else closeSidebar(); else closeSidebar();
@@ -1121,6 +1190,8 @@ function refreshDatabaseStatusLabel() {
: (transactionMode === "dry-run" ? "dry-run" : "transaction"); : (transactionMode === "dry-run" ? "dry-run" : "transaction");
databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`; databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`;
databaseStatus.classList.toggle("database-error", connectionError); databaseStatus.classList.toggle("database-error", connectionError);
databaseStateDot.className = `database-state-dot ${databaseConnectionState === "unknown" ? "is-pending" : connectionError ? "is-error" : "is-ready"}`;
databaseStateDot.setAttribute("aria-label", connectionError ? "Database disconnected" : databaseConnectionState === "unknown" ? "Database connection pending" : "Database connected");
} }
function renderDatabaseModeNote(mode) { function renderDatabaseModeNote(mode) {
@@ -1320,7 +1391,7 @@ async function loadProductInfoForCurrentProduct() {
} }
loadProductButton.disabled = true; loadProductButton.disabled = true;
loadProductButton.textContent = "Loading info..."; setButtonLabel(loadProductButton, "Loading info...");
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
@@ -1346,7 +1417,7 @@ async function loadProductInfoForCurrentProduct() {
} catch (error) { } catch (error) {
setTableMessage(error.message); setTableMessage(error.message);
} finally { } finally {
loadProductButton.textContent = "Get product info - 00"; setButtonLabel(loadProductButton, "Get product info - 00");
updateNavigationState(); updateNavigationState();
} }
} }
+1
View File
@@ -15,4 +15,5 @@
<symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3 12h18M12 3c2.3 2.4 3.4 5.4 3.4 9S14.3 18.6 12 21M12 3c-2.3 2.4-3.4 5.4-3.4 9S9.7 18.6 12 21" fill="none" stroke="currentColor" stroke-width="1.4"/></symbol> <symbol id="globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M3 12h18M12 3c2.3 2.4 3.4 5.4 3.4 9S14.3 18.6 12 21M12 3c-2.3 2.4-3.4 5.4-3.4 9S9.7 18.6 12 21" fill="none" stroke="currentColor" stroke-width="1.4"/></symbol>
<symbol id="tag" viewBox="0 0 24 24"><path d="m4 5 8.5-.5L20 12l-8 8-7.5-7.5L4 5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/></symbol> <symbol id="tag" viewBox="0 0 24 24"><path d="m4 5 8.5-.5L20 12l-8 8-7.5-7.5L4 5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/><circle cx="8" cy="8" r="1.2" fill="currentColor"/></symbol>
<symbol id="close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"/></symbol> <symbol id="close" viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18" fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="1.8"/></symbol>
<symbol id="refresh" viewBox="0 0 24 24"><path d="M20 11a8 8 0 0 0-14.8-3L3 11m0 0V5m0 6h6M4 13a8 8 0 0 0 14.8 3L21 13m0 0v6m0-6h-6" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></symbol>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

+33 -23
View File
@@ -30,12 +30,6 @@
<button class="arrow-button arrow-button-next" id="nextProductButton" type="button" title="Next product" aria-label="Next product"></button> <button class="arrow-button arrow-button-next" id="nextProductButton" type="button" title="Next product" aria-label="Next product"></button>
</div> </div>
<button class="drawer-trigger" id="parserDrawerToggle" type="button" aria-expanded="true" aria-controls="parserDrawer">
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#sliders"></use></svg>
<span>Parser tools</span>
<span class="drawer-chevron" aria-hidden="true"></span>
</button>
<div class="sidebar-section parser-drawer" id="parserDrawer"> <div class="sidebar-section parser-drawer" id="parserDrawer">
<div class="parser-actions" aria-label="Parser actions"> <div class="parser-actions" aria-label="Parser actions">
<button class="action-button" id="findSourcesButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#search"></use></svg><span>Find sources</span></button> <button class="action-button" id="findSourcesButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#search"></use></svg><span>Find sources</span></button>
@@ -57,8 +51,11 @@
</div> </div>
<div class="select-control"> <div class="select-control">
<svg class="sidebar-icon select-icon" aria-hidden="true"><use href="/icons.svg#globe"></use></svg> <div class="select-display" aria-hidden="true">
<select class="input-like" id="languageSelect" aria-label="Language"> <svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#globe"></use></svg>
<span id="languageValue">Select language</span>
</div>
<select class="input-like language-native-select" id="languageSelect" aria-label="Language">
<option value="">Select language</option> <option value="">Select language</option>
</select> </select>
<span class="select-chevron" aria-hidden="true"></span> <span class="select-chevron" aria-hidden="true"></span>
@@ -66,7 +63,7 @@
<div class="manufacturer-picker" id="manufacturerPicker"> <div class="manufacturer-picker" id="manufacturerPicker">
<button class="manufacturer-trigger" id="manufacturerTrigger" type="button" aria-haspopup="listbox" aria-expanded="false"> <button class="manufacturer-trigger" id="manufacturerTrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
<svg class="sidebar-icon select-icon" aria-hidden="true"><use href="/icons.svg#tag"></use></svg> <svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#tag"></use></svg>
<span id="manufacturerValue">Select manufacturer</span> <span id="manufacturerValue">Select manufacturer</span>
<span class="select-chevron picker-chevron" aria-hidden="true"></span> <span class="select-chevron picker-chevron" aria-hidden="true"></span>
</button> </button>
@@ -75,29 +72,39 @@
<div class="manufacturer-options" id="manufacturerOptions" role="listbox"></div> <div class="manufacturer-options" id="manufacturerOptions" role="listbox"></div>
</div> </div>
</div> </div>
<select class="input-like native-manufacturer-select" id="manufacturerSelect" aria-label="Manufacturer" hidden> <div class="native-manufacturer-host" hidden>
<select class="input-like native-manufacturer-select" id="manufacturerSelect" aria-label="Manufacturer">
<option value="">Select manufacturer</option> <option value="">Select manufacturer</option>
</select> </select>
</div>
<div class="mapping-box" id="mappingBox"> <div class="mapping-box sidebar-mapping-box" id="mappingBox">
<details class="mapping-details" id="mappingDetails"> <details class="mapping-details" id="mappingDetails">
<summary> <summary class="mapping-summary">
<span class="mapping-state is-missing" id="mappingState">Mapping not loaded</span> <span class="mapping-label">
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#layers"></use></svg>
<span class="mapping-title">Mapping</span>
<span class="mapping-state-dot is-missing" id="mappingState" role="status" aria-label="Mapping not loaded"></span>
</span>
<span class="mapping-toggle" aria-hidden="true"></span> <span class="mapping-toggle" aria-hidden="true"></span>
</summary> </summary>
<div class="mapping-panel" role="status">
<div class="mapping-detail-status" id="mappingStatusText">Mapping not loaded</div>
<div class="mapping-list" id="mappingList"></div> <div class="mapping-list" id="mappingList"></div>
</div>
</details> </details>
</div> </div>
<button class="action-button primary" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#menu"></use></svg><span>Show menu (20)</span></button>
<button class="action-button" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#scan"></use></svg><span>Scraper - 03</span></button>
<button class="action-button" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#erase"></use></svg><span>BG remover - 04</span></button>
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="app-status"> <button class="action-button primary" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#menu"></use></svg><span>Show menu (20)</span></button>
<span class="safe-mode" id="databaseStatus">DB read-only</span>
</div>
<button class="action-button" id="themeToggle" type="button" aria-pressed="false"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#moon"></use></svg><span>Dark theme</span></button> <button class="action-button" id="themeToggle" type="button" aria-pressed="false"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#moon"></use></svg><span>Dark theme</span></button>
<button class="action-button" id="settingsButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#settings"></use></svg><span>Settings</span></button> <button class="action-button" id="settingsButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#settings"></use></svg><span>Settings</span></button>
<div class="app-status">
<span class="database-status-content">
<span class="database-state-dot is-pending" id="databaseStateDot" aria-hidden="true"></span>
<span class="safe-mode" id="databaseStatus">DB read-only</span>
</span>
</div>
</div> </div>
</aside> </aside>
@@ -129,7 +136,7 @@
<section class="suggested-dialog" aria-labelledby="suggestedTitle"> <section class="suggested-dialog" aria-labelledby="suggestedTitle">
<header class="suggested-header"> <header class="suggested-header">
<h2 id="suggestedTitle">Sugested product for</h2> <h2 id="suggestedTitle">Sugested product for</h2>
<button class="modal-close" id="suggestedCloseButton" type="button">Close</button> <button class="modal-close" id="suggestedCloseButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg><span>Close</span></button>
</header> </header>
<div class="suggested-list" id="suggestedList"></div> <div class="suggested-list" id="suggestedList"></div>
</section> </section>
@@ -139,7 +146,10 @@
<section class="source-dialog" aria-labelledby="sourceTitle"> <section class="source-dialog" aria-labelledby="sourceTitle">
<header class="suggested-header"> <header class="suggested-header">
<h2 id="sourceTitle">Source candidates</h2> <h2 id="sourceTitle">Source candidates</h2>
<button class="modal-close" id="sourceCloseButton" type="button">Close</button> <div class="source-dialog-actions">
<button class="modal-close" id="sourceReloadButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#refresh"></use></svg><span>Reload</span></button>
<button class="modal-close" id="sourceCloseButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg><span>Close</span></button>
</div>
</header> </header>
<div class="source-list" id="sourceList"></div> <div class="source-list" id="sourceList"></div>
</section> </section>
@@ -149,7 +159,7 @@
<section class="settings-dialog" aria-labelledby="settingsTitle"> <section class="settings-dialog" aria-labelledby="settingsTitle">
<header class="suggested-header"> <header class="suggested-header">
<h2 id="settingsTitle">Settings</h2> <h2 id="settingsTitle">Settings</h2>
<button class="modal-close" id="settingsCloseButton" type="button">Close</button> <button class="modal-close" id="settingsCloseButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg><span>Close</span></button>
</header> </header>
<div class="settings-content"> <div class="settings-content">
<fieldset class="settings-group"> <fieldset class="settings-group">
@@ -187,7 +197,7 @@
<span>Password</span> <span>Password</span>
<input id="localDbPassword" type="text" autocomplete="off" /> <input id="localDbPassword" type="text" autocomplete="off" />
</label> </label>
<button class="settings-test-button" id="testLocalDbButton" type="button">Test connection</button> <button class="settings-test-button" id="testLocalDbButton" type="button"><svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#refresh"></use></svg><span>Test connection</span></button>
<p class="settings-note" id="localDbTestResult">Connection not tested.</p> <p class="settings-note" id="localDbTestResult">Connection not tested.</p>
</div> </div>
<p class="settings-note" id="databaseModeNote">Local 9bplus database selected.</p> <p class="settings-note" id="databaseModeNote">Local 9bplus database selected.</p>
+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -30,7 +30,10 @@ export function loadConfig({ configPath = "config/local.json", dryRun } = {}) {
database: { database: {
driver: String(env.DB_DRIVER || config.database?.driver || "endpoint").toLowerCase(), driver: String(env.DB_DRIVER || config.database?.driver || "endpoint").toLowerCase(),
mode: String(env.DB_MODE || config.database?.mode || "local").toLowerCase(), mode: String(env.DB_MODE || config.database?.mode || "local").toLowerCase(),
allowWrites: parseBoolean(env.DB_ALLOW_WRITES, config.database?.allowWrites), allowLocalWrites: parseBoolean(
env.DB_LOCAL_ALLOW_WRITES,
config.database?.allowLocalWrites ?? config.database?.allowWrites,
),
host: env.DB_HOST || config.database?.host || "127.0.0.1", host: env.DB_HOST || config.database?.host || "127.0.0.1",
port: Number(env.DB_PORT || config.database?.port || 3306), port: Number(env.DB_PORT || config.database?.port || 3306),
name: env.DB_NAME || config.database?.name || "catalog_maker_test", name: env.DB_NAME || config.database?.name || "catalog_maker_test",
+2 -2
View File
@@ -40,8 +40,8 @@ export async function executeMariaDbWrite(config, sql) {
if (config.database.mode !== "local") { if (config.database.mode !== "local") {
throw new Error("MariaDB writes are blocked outside Local DB mode."); throw new Error("MariaDB writes are blocked outside Local DB mode.");
} }
if (!config.database.allowWrites) { if (!config.database.allowLocalWrites) {
throw new Error("MariaDB writes are disabled. Enable local database writes in settings first."); throw new Error("Local MariaDB writes are disabled. Enable local database writes in Settings first.");
} }
const connection = await getPool(config).getConnection(); const connection = await getPool(config).getConnection();
+19 -1
View File
@@ -15,6 +15,7 @@ import {
loadProductByEan, loadProductByEan,
loadProductInfo, loadProductInfo,
getProductPictures, getProductPictures,
openProductSource,
} from "./services/catalog-products.ts"; } from "./services/catalog-products.ts";
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -122,6 +123,18 @@ const server = http.createServer(async (request, response) => {
return; return;
} }
if (url.pathname === "/api/catalog-maker/open-source") {
await sendJson(
response,
await openProductSource(requestConfig, {
manufacturerId: url.searchParams.get("manufacturerId"),
sourceKey: url.searchParams.get("sourceKey"),
url: url.searchParams.get("url"),
}),
);
return;
}
if (url.pathname === "/api/catalog-maker/product-info") { if (url.pathname === "/api/catalog-maker/product-info") {
await sendJson( await sendJson(
response, response,
@@ -197,7 +210,9 @@ function getStatus() {
driver: config.database.driver, driver: config.database.driver,
mode: config.database.mode, mode: config.database.mode,
scrapeName: config.database.scrapeName, scrapeName: config.database.scrapeName,
allowWrites: config.database.driver === "mariadb" && config.database.mode === "local" && config.database.allowWrites, allowLocalWrites: config.database.driver === "mariadb"
&& config.database.mode === "local"
&& config.database.allowLocalWrites,
}, },
workbook: path.basename(config.sourceWorkbook), workbook: path.basename(config.sourceWorkbook),
workbookExists: true, workbookExists: true,
@@ -230,6 +245,9 @@ function getRequestConfig(request) {
database: { database: {
...config.database, ...config.database,
mode: selectedMode, mode: selectedMode,
allowLocalWrites: selectedMode === "local"
&& config.database.allowLocalWrites
&& request.headers["x-local-write-access"] === "1",
}, },
}; };
} }
+33 -6
View File
@@ -1,18 +1,19 @@
// @ts-nocheck // @ts-nocheck
import { chromium } from "playwright"; import fs from "node:fs";
import { chromium, firefox } from "playwright";
const visibleBrowsers = new Set(); const visibleBrowsers = new Set();
export async function openInBrowser(url) { export async function openInBrowser(url, { engine = "chromium" } = {}) {
const browser = await chromium.launch({ headless: false }); const browser = await launchBrowser(engine, false);
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 }); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
visibleBrowsers.add(browser); visibleBrowsers.add(browser);
return { browser, page }; return { browser, page };
} }
export async function searchInBrowser({ url, headless = true, waitAfterLoadMs = 3000 }) { export async function searchInBrowser({ url, engine = "chromium", headless = true, waitAfterLoadMs = 3000 }) {
const browser = await chromium.launch({ headless }); const browser = await launchBrowser(engine, headless);
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
try { try {
@@ -44,7 +45,33 @@ export async function searchInBrowser({ url, headless = true, waitAfterLoadMs =
} }
export async function scrapePage(url, options = {}) { export async function scrapePage(url, options = {}) {
return searchInBrowser({ url, headless: true, waitAfterLoadMs: 750, ...options }); return searchInBrowser({ url, engine: "chromium", headless: true, waitAfterLoadMs: 750, ...options });
}
function launchBrowser(engine, headless) {
const normalizedEngine = String(engine).toLowerCase() === "firefox" ? "firefox" : "chromium";
const browserType = normalizedEngine === "firefox" ? firefox : chromium;
const executablePath = resolveSystemBrowser(normalizedEngine);
const options = {
headless,
timeout: 10_000,
...(executablePath ? { executablePath } : {}),
};
return browserType.launch(options);
}
function resolveSystemBrowser(engine) {
if (process.platform !== "win32") return "";
if (engine === "firefox") return "";
const candidates = [
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
];
return candidates.find((candidate) => fs.existsSync(candidate)) || "";
} }
export async function closeVisibleBrowsers() { export async function closeVisibleBrowsers() {
+45 -11
View File
@@ -13,6 +13,7 @@ import {
export async function loadManufacturerCatalogState(config, { manufacturerId, position = 1 }) { export async function loadManufacturerCatalogState(config, { manufacturerId, position = 1 }) {
const refreshResults = []; const refreshResults = [];
if (config.database?.mode === "local" && config.database.allowLocalWrites) {
for (const sql of prepareManufacturerCatalogSqls({ manufacturerId })) { for (const sql of prepareManufacturerCatalogSqls({ manufacturerId })) {
refreshResults.push( refreshResults.push(
await executeEndpointWrite(config, sql, { await executeEndpointWrite(config, sql, {
@@ -20,6 +21,7 @@ export async function loadManufacturerCatalogState(config, { manufacturerId, pos
}), }),
); );
} }
}
const productResult = await queryEndpoint( const productResult = await queryEndpoint(
config, config,
@@ -49,6 +51,7 @@ export async function loadManufacturerCatalogState(config, { manufacturerId, pos
refresh: { refresh: {
operation: "catalog-maker-refresh-manufacturer", operation: "catalog-maker-refresh-manufacturer",
statements: refreshResults.length, statements: refreshResults.length,
skipped: refreshResults.length === 0,
}, },
product: normalizeProduct(product), product: normalizeProduct(product),
combinations: combinationsResult.items.map(normalizeCombination), combinations: combinationsResult.items.map(normalizeCombination),
@@ -182,7 +185,7 @@ export async function findProductSources(
const candidate = item.candidates?.[0]; const candidate = item.candidates?.[0];
if (candidate?.url) { if (candidate?.url) {
try { try {
await openInBrowser(candidate.url); await openInBrowser(candidate.url, { engine: item.source.browserEngine });
} catch { } catch {
// Picture extraction can continue even if the visible browser cannot start. // Picture extraction can continue even if the visible browser cannot start.
} }
@@ -237,6 +240,31 @@ export async function getProductPictures(
}; };
} }
export async function openProductSource(config, { manufacturerId, sourceKey, url }) {
const sourceUrl = normalizeSourceUrl(url);
const mapping = await listMappingSources(config, { manufacturerId });
const source = mapping.items.find(
(item) => item.enabled && String(item.key ?? "").toLowerCase() === String(sourceKey ?? "").toLowerCase(),
);
if (!source) {
throw new Error("Source mapping was not found for selected manufacturer.");
}
await openInBrowser(sourceUrl, { engine: source.browserEngine });
return {
configured: mapping.configured,
opened: true,
source: {
key: source.key,
name: source.name,
browserEngine: source.browserEngine,
},
url: sourceUrl,
};
}
async function findSourceCandidates(source, product, { includePictures = false } = {}) { async function findSourceCandidates(source, product, { includePictures = false } = {}) {
const searchAttempts = buildAdapterSearchUrls(source, product); const searchAttempts = buildAdapterSearchUrls(source, product);
let lastSearchUrl = searchAttempts[0]?.url || normalizeSourceUrl(source.url); let lastSearchUrl = searchAttempts[0]?.url || normalizeSourceUrl(source.url);
@@ -258,6 +286,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
try { try {
const result = await searchInBrowser({ const result = await searchInBrowser({
url: attempt.url, url: attempt.url,
engine: source.browserEngine,
headless: source.browserHeadless, headless: source.browserHeadless,
waitAfterLoadMs: source.waitAfterLoadMs, waitAfterLoadMs: source.waitAfterLoadMs,
}); });
@@ -289,7 +318,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
}; };
} catch (error) { } catch (error) {
try { try {
await openInBrowser(attempt.url); await openInBrowser(attempt.url, { engine: source.browserEngine });
} catch { } catch {
// The manual candidate below remains available even if the browser cannot start. // The manual candidate below remains available even if the browser cannot start.
} }
@@ -309,6 +338,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
lastSearchUrl = attempt.url; lastSearchUrl = attempt.url;
try { try {
const response = await scrapePage(attempt.url, { const response = await scrapePage(attempt.url, {
engine: source.browserEngine,
waitAfterLoadMs: source.waitAfterLoadMs, waitAfterLoadMs: source.waitAfterLoadMs,
}); });
const html = response.pageSource; const html = response.pageSource;
@@ -327,7 +357,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
if (candidates.length) { if (candidates.length) {
let verification = null; let verification = null;
if (includePictures && source.key === "hudy" && product.color && candidates[0].url) { if (includePictures && source.key === "hudy" && product.color && candidates[0].url) {
verification = await verifyHudyVariant(candidates[0].url, product); verification = await verifyHudyVariant(candidates[0].url, product, source.browserEngine);
candidates[0].verification = verification; candidates[0].verification = verification;
if (verification.matchedUrl) candidates[0].url = verification.matchedUrl; if (verification.matchedUrl) candidates[0].url = verification.matchedUrl;
if (includePictures && verification.pictures?.length) { if (includePictures && verification.pictures?.length) {
@@ -338,7 +368,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
if (includePictures) { if (includePictures) {
for (const candidate of candidates) { for (const candidate of candidates) {
if (source.key === "hudy" && candidate.url) { if (source.key === "hudy" && candidate.url) {
candidate.pictures = await extractPicturesFromPage(candidate.url); candidate.pictures = await extractPicturesFromPage(candidate.url, source.browserEngine);
} else { } else {
candidate.pictures = extractPictureUrls(html, response.url || attempt.url); candidate.pictures = extractPictureUrls(html, response.url || attempt.url);
} }
@@ -580,7 +610,7 @@ function findColorVariantUrl(html, pageUrl, color) {
return ""; return "";
} }
async function verifyHudyVariant(url, product) { async function verifyHudyVariant(url, product, browserEngine = "chromium") {
try { try {
const eanGroup = Array.isArray(product.eanGroup) const eanGroup = Array.isArray(product.eanGroup)
? product.eanGroup ? product.eanGroup
@@ -598,7 +628,7 @@ async function verifyHudyVariant(url, product) {
if (visited.has(page.url)) continue; if (visited.has(page.url)) continue;
visited.add(page.url); visited.add(page.url);
const response = await scrapePage(page.url, { waitAfterLoadMs: 750 }); const response = await scrapePage(page.url, { engine: browserEngine, waitAfterLoadMs: 750 });
const html = response.pageSource; const html = response.pageSource;
lastStatus = response.status; lastStatus = response.status;
const pageEans = extractPageEans(html); const pageEans = extractPageEans(html);
@@ -700,9 +730,9 @@ function extractPictureUrls(html, pageUrl) {
return urls.slice(0, 12); return urls.slice(0, 12);
} }
async function extractPicturesFromPage(url) { async function extractPicturesFromPage(url, browserEngine = "chromium") {
try { try {
const response = await scrapePage(url, { waitAfterLoadMs: 750 }); const response = await scrapePage(url, { engine: browserEngine, waitAfterLoadMs: 750 });
return extractHudyGalleryPictureUrls(response.pageSource, response.url || url); return extractHudyGalleryPictureUrls(response.pageSource, response.url || url);
} catch { } catch {
return []; return [];
@@ -855,16 +885,20 @@ async function checkMappingSourceUrls(items) {
return { return {
...item, ...item,
...(await checkUrl(item.url)), ...(await checkUrl(item.url, item.browserEngine, item.browserHeadless)),
}; };
}), }),
); );
} }
async function checkUrl(url) { async function checkUrl(url, browserEngine = "chromium", headless = true) {
try { try {
const target = normalizeSourceUrl(url); const target = normalizeSourceUrl(url);
const response = await scrapePage(target, { waitAfterLoadMs: 250 }); const response = await scrapePage(target, {
engine: browserEngine,
headless,
waitAfterLoadMs: 250,
});
return { return {
health: response.ok ? "ok" : "warning", health: response.ok ? "ok" : "warning",
-2
View File
@@ -93,7 +93,6 @@ export function productToDoByManufacturerSql({ manufacturerId, position = 1 }) {
tr.total, tr.total,
p.id_product, p.id_product,
p.price, p.price,
p.id_supplier,
( (
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg') SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
FROM ps_image i FROM ps_image i
@@ -155,7 +154,6 @@ export function productByEanSql({ manufacturerId, ean }) {
tr.total, tr.total,
p.id_product, p.id_product,
p.price, p.price,
p.id_supplier,
( (
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg') SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
FROM ps_image i FROM ps_image i
+42 -18
View File
@@ -58,10 +58,10 @@
.sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; } .sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
.sidebar-section { @apply border-t border-slate-700 pt-1; } .sidebar-section { @apply border-t border-slate-700 pt-1; }
.drawer-trigger { @apply relative flex min-h-8 w-full items-center justify-center rounded border border-slate-700 bg-slate-800 px-8 text-center text-[11px] font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-700; } .drawer-trigger { @apply relative flex min-h-8 w-full items-center justify-center rounded border border-slate-700 bg-slate-800 px-8 text-center text-[11px] font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-700; }
.drawer-trigger .sidebar-icon { @apply absolute left-2; } .drawer-trigger .sidebar-icon { @apply static; }
.drawer-trigger > span:not(.drawer-chevron) { @apply min-w-0 truncate text-center; } .drawer-trigger > span:not(.drawer-chevron) { @apply min-w-0 truncate text-center; }
.drawer-trigger .drawer-chevron { @apply inline-flex h-2 w-2 border-b-2 border-r-2 text-[0px] leading-none; } .drawer-trigger .drawer-chevron { @apply absolute right-2 inline-flex h-2 w-2 border-b-2 border-r-2 text-[0px] leading-none; }
.drawer-trigger .drawer-chevron { @apply ml-auto rotate-45 text-slate-300; } .drawer-trigger .drawer-chevron { @apply rotate-45 text-slate-300; }
.drawer-trigger[aria-expanded="true"] .drawer-chevron { @apply rotate-[225deg]; } .drawer-trigger[aria-expanded="true"] .drawer-chevron { @apply rotate-[225deg]; }
.parser-drawer[hidden] { display: none; } .parser-drawer[hidden] { display: none; }
.sidebar-icon { @apply inline-flex h-4 w-4 shrink-0 items-center justify-center text-sm leading-none text-slate-300; } .sidebar-icon { @apply inline-flex h-4 w-4 shrink-0 items-center justify-center text-sm leading-none text-slate-300; }
@@ -71,6 +71,11 @@
.action-button .sidebar-icon { @apply text-slate-400; } .action-button .sidebar-icon { @apply text-slate-400; }
.sidebar-footer { @apply mt-auto grid gap-1 border-t border-slate-700 pt-2; } .sidebar-footer { @apply mt-auto grid gap-1 border-t border-slate-700 pt-2; }
.app-status { @apply rounded border border-slate-700 bg-slate-950 p-1 text-[10px] leading-tight text-slate-100; } .app-status { @apply rounded border border-slate-700 bg-slate-950 p-1 text-[10px] leading-tight text-slate-100; }
.database-status-content { @apply flex items-center justify-center gap-1.5 text-center; }
.database-state-dot { @apply inline-block h-2.5 w-2.5 shrink-0 rounded-full border border-slate-500; }
.database-state-dot.is-ready { @apply border-emerald-300 bg-emerald-500; }
.database-state-dot.is-pending { @apply border-amber-300 bg-amber-400; }
.database-state-dot.is-error { @apply border-red-300 bg-red-500; }
.safe-mode, #modePill { @apply inline-block rounded bg-slate-100 px-1 py-0.5 text-[10px] font-semibold text-slate-800; } .safe-mode, #modePill { @apply inline-block rounded bg-slate-100 px-1 py-0.5 text-[10px] font-semibold text-slate-800; }
#databaseStatus.database-error { @apply bg-red-100 text-red-700; } #databaseStatus.database-error { @apply bg-red-100 text-red-700; }
.record-id { @apply flex h-10 items-center justify-center rounded border border-lime-700 bg-catalog-accent text-[26px] font-bold leading-none text-catalog-accent-text; } .record-id { @apply flex h-10 items-center justify-center rounded border border-lime-700 bg-catalog-accent text-[26px] font-bold leading-none text-catalog-accent-text; }
@@ -90,13 +95,15 @@
.arrow-button:disabled, .action-button:disabled { @apply cursor-not-allowed opacity-50; } .arrow-button:disabled, .action-button:disabled { @apply cursor-not-allowed opacity-50; }
.input-like { @apply h-7 px-1 text-[11px]; } .input-like { @apply h-7 px-1 text-[11px]; }
.select-control { @apply relative w-full; } .select-control { @apply relative w-full; }
.select-control .input-like { @apply appearance-none pr-7 pl-9 text-center; } .select-display { @apply pointer-events-none absolute inset-0 z-30 flex items-center justify-center gap-1.5 text-center text-slate-200; }
.select-icon { @apply pointer-events-none absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-slate-300; } .select-display span { @apply min-w-0 max-w-[calc(100%-3rem)] truncate; }
.select-chevron { @apply pointer-events-none absolute right-3 top-1/2 h-2 w-2 -translate-y-1/2 rotate-45 border-b-2 border-r-2 text-slate-200; } .language-native-select { @apply relative z-20 cursor-pointer appearance-none bg-transparent pr-7 pl-7 !text-transparent caret-transparent; }
.language-native-select:focus { @apply !text-transparent; }
.select-chevron { @apply pointer-events-none absolute right-3 top-1/2 z-30 h-2 w-2 -translate-y-1/2 rotate-45 border-b-2 border-r-2 text-slate-200; }
.native-manufacturer-host { @apply hidden; }
.native-manufacturer-select { @apply hidden; } .native-manufacturer-select { @apply hidden; }
.manufacturer-picker { @apply relative; } .manufacturer-picker { @apply relative; }
.manufacturer-trigger { @apply relative flex h-7 w-full items-center justify-center rounded border border-slate-700 bg-slate-800 px-9 text-center text-[11px] text-slate-200 shadow-sm; } .manufacturer-trigger { @apply relative flex h-7 w-full items-center justify-center gap-1.5 rounded border border-slate-700 bg-slate-800 px-9 text-center text-[11px] text-slate-200 shadow-sm; }
.manufacturer-trigger .select-icon { @apply left-3; }
.manufacturer-trigger > span:not(.select-chevron) { @apply min-w-0 truncate text-center; } .manufacturer-trigger > span:not(.select-chevron) { @apply min-w-0 truncate text-center; }
.manufacturer-trigger:hover { @apply border-slate-500 bg-slate-700; } .manufacturer-trigger:hover { @apply border-slate-500 bg-slate-700; }
.manufacturer-trigger[aria-expanded="true"] { @apply border-catalog-focus ring-2 ring-catalog-focus/25; } .manufacturer-trigger[aria-expanded="true"] { @apply border-catalog-focus ring-2 ring-catalog-focus/25; }
@@ -112,14 +119,24 @@
.input-like:focus { @apply border-catalog-focus outline-none ring-2 ring-catalog-focus/25; } .input-like:focus { @apply border-catalog-focus outline-none ring-2 ring-catalog-focus/25; }
.picture-slot { @apply aspect-square w-full overflow-hidden rounded border border-dashed border-slate-500 bg-catalog-panel-soft p-1 text-center text-[11px] text-slate-200; } .picture-slot { @apply aspect-square w-full overflow-hidden rounded border border-dashed border-slate-500 bg-catalog-panel-soft p-1 text-center text-[11px] text-slate-200; }
.picture-slot img { @apply h-full w-full object-contain; } .picture-slot img { @apply h-full w-full object-contain; }
.mapping-box { @apply rounded border border-slate-500 bg-catalog-panel-soft; } .mapping-box { @apply relative rounded border border-slate-500 bg-catalog-panel-soft; }
.mapping-details summary { @apply grid cursor-pointer grid-cols-[minmax(0,1fr)_22px] items-center px-1 py-1 list-none; } .sidebar-mapping-box { @apply overflow-hidden border-slate-700 bg-slate-900; }
.sidebar-mapping-box { @apply overflow-visible; }
.mapping-details { @apply relative; }
.mapping-summary { @apply relative flex min-h-8 items-center justify-center rounded bg-slate-800 px-9 text-center text-[11px] font-semibold text-slate-100; }
.mapping-label { @apply absolute left-1/2 top-1/2 inline-flex max-w-[calc(100%-4.5rem)] -translate-x-1/2 -translate-y-1/2 items-center justify-center gap-1.5 text-center; }
.mapping-title { @apply min-w-0 truncate text-center; }
.mapping-state-dot { @apply inline-block h-3 w-3 shrink-0 rounded-full border border-slate-500 shadow-sm; }
.mapping-state-dot.is-ready { @apply border-emerald-300 bg-emerald-500; }
.mapping-state-dot.is-warning { @apply border-amber-300 bg-amber-400; }
.mapping-state-dot.is-missing { @apply border-red-300 bg-red-500; }
.mapping-state-dot.is-loading { @apply border-slate-300 bg-slate-400; }
.mapping-panel { @apply absolute left-0 right-0 top-full z-50 border border-t-0 border-slate-700 bg-slate-950/95 shadow-lg; }
.mapping-detail-status { @apply truncate px-2 py-1 text-center text-[10px] font-normal text-slate-300; }
.mapping-summary .mapping-toggle { @apply absolute right-2 top-1/2 flex h-4 w-4 -translate-y-1/2 items-center justify-center; }
.mapping-details summary { @apply cursor-pointer list-none; }
.mapping-details summary::-webkit-details-marker { display: none; } .mapping-details summary::-webkit-details-marker { display: none; }
.mapping-toggle::before { content: ""; @apply inline-block text-center text-lg leading-none text-slate-100; } .mapping-toggle::before { content: ""; @apply block h-2 w-2 rotate-45 border-b-2 border-r-2 border-slate-100; }
.mapping-details[open] .mapping-toggle::before { content: "⌄"; }
.mapping-state { @apply block truncate rounded px-1 py-0.5 text-[10px] font-normal; }
.mapping-state.is-ready { @apply bg-slate-100 text-slate-800; }
.mapping-state.is-missing { @apply bg-red-100 text-red-700; }
.mapping-list { @apply grid gap-0.5 px-1 pb-1; } .mapping-list { @apply grid gap-0.5 px-1 pb-1; }
.mapping-source-row { @apply grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-[10px] text-slate-800; } .mapping-source-row { @apply grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1 rounded bg-slate-100 px-1 py-0.5 text-[10px] text-slate-800; }
.mapping-source-row.is-disabled { @apply opacity-50; } .mapping-source-row.is-disabled { @apply opacity-50; }
@@ -165,12 +182,14 @@
.modal-backdrop { @apply fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4; } .modal-backdrop { @apply fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 p-4; }
.modal-backdrop[hidden] { display: none; } .modal-backdrop[hidden] { display: none; }
.suggested-dialog, .source-dialog, .settings-dialog { @apply max-h-[90vh] w-full overflow-hidden rounded-lg border border-slate-300 bg-slate-50 shadow-2xl; } .suggested-dialog, .source-dialog, .settings-dialog { @apply max-h-[90vh] w-full overflow-hidden rounded-lg border border-slate-300 bg-slate-50 shadow-2xl; }
.source-dialog-actions { @apply flex items-center gap-1.5; }
.suggested-dialog { @apply max-w-2xl; } .suggested-dialog { @apply max-w-2xl; }
.source-dialog { @apply max-w-3xl; } .source-dialog { @apply max-w-3xl; }
.settings-dialog { @apply max-w-md; } .settings-dialog { @apply max-w-md; }
.suggested-header { @apply flex items-center justify-between border-b border-slate-300 bg-slate-200 px-3 py-2; } .suggested-header { @apply flex items-center justify-between border-b border-slate-300 bg-slate-200 px-3 py-2; }
.suggested-header h2 { @apply m-0 text-sm font-semibold text-slate-800; } .suggested-header h2 { @apply m-0 text-sm font-semibold text-slate-800; }
.modal-close, .settings-test-button { @apply rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-800 shadow-sm hover:bg-slate-100; } .modal-close, .settings-test-button { @apply inline-flex items-center justify-center gap-1.5 rounded border border-slate-300 bg-white px-2 py-1 text-[11px] text-slate-800 shadow-sm hover:bg-slate-100; }
.modal-close .sidebar-icon, .settings-test-button .sidebar-icon { @apply h-3.5 w-3.5 text-slate-500; }
.suggested-list, .source-list, .settings-content { @apply max-h-[calc(90vh-48px)] overflow-y-auto p-2; } .suggested-list, .source-list, .settings-content { @apply max-h-[calc(90vh-48px)] overflow-y-auto p-2; }
.settings-content { @apply grid gap-2; } .settings-content { @apply grid gap-2; }
.settings-group { @apply m-0 rounded border border-slate-300 p-2; } .settings-group { @apply m-0 rounded border border-slate-300 p-2; }
@@ -247,7 +266,8 @@
html:not(.dark) .sidebar-drawer-title { @apply text-slate-700; } html:not(.dark) .sidebar-drawer-title { @apply text-slate-700; }
html:not(.dark) .drawer-trigger .drawer-chevron, html:not(.dark) .drawer-trigger .drawer-chevron,
html:not(.dark) .select-chevron, html:not(.dark) .select-chevron,
html:not(.dark) .select-icon { @apply text-slate-700; } html:not(.dark) .select-display,
html:not(.dark) .manufacturer-trigger .sidebar-icon { @apply text-slate-700; }
html:not(.dark) .sidebar-close-button, html:not(.dark) .sidebar-close-button,
html:not(.dark) .sidebar-open-button, html:not(.dark) .sidebar-open-button,
html:not(.dark) .drawer-trigger, html:not(.dark) .drawer-trigger,
@@ -264,8 +284,12 @@
html:not(.dark) #scanInput { @apply bg-slate-200 placeholder:text-slate-500; } html:not(.dark) #scanInput { @apply bg-slate-200 placeholder:text-slate-500; }
html:not(.dark) .picture-slot, html:not(.dark) .picture-slot,
html:not(.dark) .mapping-box { @apply border-slate-300 bg-white text-slate-600; } html:not(.dark) .mapping-box { @apply border-slate-300 bg-white text-slate-600; }
html:not(.dark) .mapping-toggle::before { @apply text-slate-700; } html:not(.dark) .mapping-toggle::before { @apply border-slate-700; }
html:not(.dark) .app-status { @apply border-slate-300 bg-white text-slate-700; } html:not(.dark) .app-status { @apply border-slate-300 bg-white text-slate-700; }
html:not(.dark) .sidebar-mapping-box { @apply border-slate-300 bg-slate-100; }
html:not(.dark) .mapping-summary { @apply border border-slate-300 bg-white text-slate-700; }
html:not(.dark) .mapping-panel { @apply border-slate-300 bg-white; }
html:not(.dark) .mapping-detail-status { @apply text-slate-600; }
html:not(.dark) .manufacturer-trigger { @apply border-slate-300 bg-white text-slate-700; } html:not(.dark) .manufacturer-trigger { @apply border-slate-300 bg-white text-slate-700; }
html:not(.dark) .manufacturer-trigger:hover { @apply border-slate-400 bg-slate-200; } html:not(.dark) .manufacturer-trigger:hover { @apply border-slate-400 bg-slate-200; }
html.contrast-light-high:not(.dark) .control-panel { @apply bg-slate-200 text-slate-900; } html.contrast-light-high:not(.dark) .control-panel { @apply bg-slate-200 text-slate-900; }