Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17082f6d9e | ||
|
|
fe329cd19b | ||
|
|
d90e6c6dd8 | ||
|
|
6a1568a1dd | ||
|
|
1f5ab5a8cc | ||
|
|
e172b24474 | ||
|
|
bc96d50ecc | ||
|
|
b9c2e181eb | ||
|
|
8b2a74bcb3 | ||
|
|
1ab09903fb | ||
|
|
f08699fad3 | ||
|
|
2f3444adb8 | ||
|
|
e6acb21d65 | ||
|
|
c58084cc86 | ||
|
|
f97d00ab7f | ||
|
|
f2fa405008 | ||
|
|
85df4dc4cb | ||
|
|
90731319c8 | ||
|
|
0f9764cc49 | ||
|
|
921890fbec | ||
|
|
657c82b4ba | ||
|
|
071c37cb3a | ||
|
|
c440b6fc1b | ||
|
|
8d5521093e | ||
|
|
3a707aa0ca | ||
|
|
452ee68599 | ||
|
|
1a8180c1f9 | ||
|
|
1258d64aef | ||
|
|
9d9789cd5b | ||
|
|
dab0859aa9 | ||
|
|
7c7d5d2952 | ||
|
|
0269a20187 | ||
|
|
d5b4f344dd | ||
|
|
c94b514647 | ||
|
|
588c0bd9cc | ||
|
|
e9aa381f45 | ||
|
|
5882edb994 | ||
|
|
310e1e239f | ||
|
|
4e1c3eccab | ||
|
|
f463bd27e7 | ||
|
|
e68c5d2bc7 |
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -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.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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/`.
|
||||
- The current server is `src/server.ts`, run through `tsx`.
|
||||
- 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
|
||||
|
||||
@@ -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 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 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.
|
||||
- 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.
|
||||
@@ -140,6 +150,7 @@ There are three selectable data sources:
|
||||
- `Get product info` remains a separate action.
|
||||
- EAN scan input searches on Enter.
|
||||
- `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.
|
||||
- Each source row has its own `Get pictures` action.
|
||||
- Product source cache is cleared when a new product loads.
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"database": {
|
||||
"driver": "endpoint",
|
||||
"mode": "local",
|
||||
"allowWrites": false,
|
||||
"allowLocalWrites": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"name": "9bplus",
|
||||
|
||||
+106
-35
@@ -8,6 +8,7 @@ const manufacturerSearch = document.querySelector("#manufacturerSearch");
|
||||
const manufacturerOptions = document.querySelector("#manufacturerOptions");
|
||||
const manufacturerValue = document.querySelector("#manufacturerValue");
|
||||
const languageSelect = document.querySelector("#languageSelect");
|
||||
const languageValue = document.querySelector("#languageValue");
|
||||
const recordIdElement = document.querySelector("#recordId");
|
||||
const loadProductButton = document.querySelector("#loadProductButton");
|
||||
const previousProductButton = document.querySelector("#previousProductButton");
|
||||
@@ -21,12 +22,11 @@ const suggestedTitle = document.querySelector("#suggestedTitle");
|
||||
const suggestedList = document.querySelector("#suggestedList");
|
||||
const suggestedCloseButton = document.querySelector("#suggestedCloseButton");
|
||||
const mappingState = document.querySelector("#mappingState");
|
||||
const mappingStatusText = document.querySelector("#mappingStatusText");
|
||||
const mappingList = document.querySelector("#mappingList");
|
||||
const mappingDetails = document.querySelector("#mappingDetails");
|
||||
const findSourcesButton = document.querySelector("#findSourcesButton");
|
||||
const getPicturesButton = document.querySelector("#getPicturesButton");
|
||||
const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
|
||||
const parserDrawer = document.querySelector("#parserDrawer");
|
||||
const sidebarDrawer = document.querySelector("#sidebarDrawer");
|
||||
const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
|
||||
const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
|
||||
@@ -34,6 +34,7 @@ const sidebarOverlay = document.querySelector("#sidebarOverlay");
|
||||
const sourceModal = document.querySelector("#sourceModal");
|
||||
const sourceTitle = document.querySelector("#sourceTitle");
|
||||
const sourceList = document.querySelector("#sourceList");
|
||||
const sourceReloadButton = document.querySelector("#sourceReloadButton");
|
||||
const sourceCloseButton = document.querySelector("#sourceCloseButton");
|
||||
const settingsButton = document.querySelector("#settingsButton");
|
||||
const themeToggle = document.querySelector("#themeToggle");
|
||||
@@ -44,6 +45,7 @@ const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]
|
||||
const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle");
|
||||
const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]');
|
||||
const databaseStatus = document.querySelector("#databaseStatus");
|
||||
const databaseStateDot = document.querySelector("#databaseStateDot");
|
||||
const localDbFields = document.querySelector("#localDbFields");
|
||||
const localDbInputs = document.querySelectorAll("#localDbFields input");
|
||||
const testLocalDbButton = document.querySelector("#testLocalDbButton");
|
||||
@@ -61,9 +63,16 @@ window.fetch = (input, init = {}) => {
|
||||
return nativeFetch(input, init);
|
||||
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
|
||||
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.
|
||||
headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local");
|
||||
headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0");
|
||||
headers.set("X-Local-Write-Access", localWritesEnabled ? "1" : "0");
|
||||
return nativeFetch(input, { ...init, headers });
|
||||
};
|
||||
let currentPosition = 1;
|
||||
@@ -74,6 +83,7 @@ let loadRequestId = 0;
|
||||
let applicationMode = "dry-run";
|
||||
let databaseConnectionState = "unknown";
|
||||
const localDraftKey = "catalog-maker:current-product-draft:v2";
|
||||
const selectedManufacturerKey = "catalog-maker:selected-manufacturer";
|
||||
let localDraftProductKey = "";
|
||||
const fieldNames = [
|
||||
"id_manufacturer",
|
||||
@@ -143,7 +153,14 @@ renderTableHeader();
|
||||
setTableWidth();
|
||||
await loadManufacturers();
|
||||
await loadLanguages();
|
||||
languageSelect.addEventListener("change", syncLanguagePickerLabel);
|
||||
manufacturerSelect.addEventListener("change", () => {
|
||||
if (manufacturerSelect.value) {
|
||||
localStorage.setItem(selectedManufacturerKey, manufacturerSelect.value);
|
||||
}
|
||||
else {
|
||||
localStorage.removeItem(selectedManufacturerKey);
|
||||
}
|
||||
syncManufacturerPickerLabel();
|
||||
closeManufacturerPicker();
|
||||
if (manufacturerSelect.value) {
|
||||
@@ -169,8 +186,8 @@ loadProductButton.disabled = true;
|
||||
loadProductButton.title = "Load existing product attribute info.";
|
||||
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
|
||||
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
|
||||
sourceReloadButton.addEventListener("click", () => findSourcesForCurrentProduct(true));
|
||||
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
|
||||
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
|
||||
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
|
||||
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
|
||||
sidebarOpenButton.addEventListener("click", openSidebar);
|
||||
@@ -288,15 +305,29 @@ async function loadManufacturers() {
|
||||
}
|
||||
if (data.message)
|
||||
manufacturerSelect.append(createOption("", data.message));
|
||||
restoreSelectedManufacturer();
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
}
|
||||
catch (error) {
|
||||
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
|
||||
restoreSelectedManufacturer();
|
||||
syncManufacturerPickerLabel();
|
||||
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() {
|
||||
if (manufacturerMenu.hidden) {
|
||||
manufacturerMenu.hidden = false;
|
||||
@@ -349,6 +380,15 @@ function createOption(value, label) {
|
||||
option.textContent = label;
|
||||
return option;
|
||||
}
|
||||
function setButtonLabel(button, label) {
|
||||
const labelElement = button.querySelector(":scope > span");
|
||||
if (labelElement) {
|
||||
labelElement.textContent = label;
|
||||
}
|
||||
else {
|
||||
button.textContent = label;
|
||||
}
|
||||
}
|
||||
async function loadLanguages() {
|
||||
languageSelect.replaceChildren(createOption("", "Select language"));
|
||||
try {
|
||||
@@ -366,6 +406,11 @@ async function loadLanguages() {
|
||||
catch (error) {
|
||||
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() {
|
||||
const manufacturerId = manufacturerSelect.value;
|
||||
@@ -373,8 +418,7 @@ async function loadMappingSources() {
|
||||
renderMappingSources({ mapped: false, items: [], message: "Select manufacturer." });
|
||||
return;
|
||||
}
|
||||
mappingState.textContent = "Loading mapping...";
|
||||
mappingState.className = "mapping-state";
|
||||
setMappingStatus("Loading mapping...", "is-loading");
|
||||
mappingList.replaceChildren();
|
||||
try {
|
||||
const params = new URLSearchParams({ manufacturerId, check: "1" });
|
||||
@@ -396,21 +440,20 @@ async function loadMappingSources() {
|
||||
function renderMappingSources(data) {
|
||||
const items = data.items || [];
|
||||
if (!data.mapped || !items.length) {
|
||||
mappingState.textContent = data.message || "Mapping missing";
|
||||
mappingState.className = "mapping-state is-missing";
|
||||
setMappingStatus(data.message || "Mapping missing", "is-missing");
|
||||
mappingList.replaceChildren();
|
||||
mappingDetails.open = true;
|
||||
mappingDetails.open = false;
|
||||
return;
|
||||
}
|
||||
const enabledCount = items.filter((item) => item.enabled).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 ready = enabledCount > 0 && failedCount === 0;
|
||||
mappingState.textContent = ready
|
||||
const statusText = ready
|
||||
? `Sources OK ✓ (${testedCount}/${enabledCount})`
|
||||
: data.message || `Sources problem (${testedCount}/${enabledCount})`;
|
||||
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing";
|
||||
mappingDetails.open = !ready || failedCount > 0;
|
||||
setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing");
|
||||
mappingDetails.open = false;
|
||||
mappingList.replaceChildren(...items.map((item) => {
|
||||
const row = document.createElement("div");
|
||||
row.className = `mapping-source-row${item.enabled ? "" : " is-disabled"}`;
|
||||
@@ -424,6 +467,12 @@ function renderMappingSources(data) {
|
||||
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) {
|
||||
if (!item.enabled)
|
||||
return "off";
|
||||
@@ -599,13 +648,13 @@ function closeSuggestedProducts() {
|
||||
suggestedModal.hidden = true;
|
||||
suggestedList.replaceChildren();
|
||||
}
|
||||
async function findSourcesForCurrentProduct() {
|
||||
async function findSourcesForCurrentProduct(forceRefresh = false) {
|
||||
if (!manufacturerSelect.value || !currentProduct) {
|
||||
setTableMessage("Select manufacturer and product first.");
|
||||
return;
|
||||
}
|
||||
const savedDraft = readLocalDraft();
|
||||
if (savedDraft?.sources) {
|
||||
if (savedDraft?.sources && !forceRefresh) {
|
||||
renderSourceCandidates(savedDraft.sources.items || []);
|
||||
updateFindSourcesButtonState();
|
||||
sourceTitle.textContent = "Source candidates (cached)";
|
||||
@@ -614,7 +663,8 @@ async function findSourcesForCurrentProduct() {
|
||||
}
|
||||
const supplierColorGroup = getFirstSupplierColorGroup();
|
||||
findSourcesButton.disabled = true;
|
||||
findSourcesButton.textContent = "Finding sources...";
|
||||
sourceReloadButton.disabled = true;
|
||||
setButtonLabel(findSourcesButton, "Finding sources...");
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
manufacturerId: manufacturerSelect.value,
|
||||
@@ -642,6 +692,7 @@ async function findSourcesForCurrentProduct() {
|
||||
setTableMessage(error.message || "Source search failed.");
|
||||
}
|
||||
finally {
|
||||
sourceReloadButton.disabled = false;
|
||||
updateFindSourcesButtonState();
|
||||
}
|
||||
}
|
||||
@@ -660,7 +711,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
|
||||
}
|
||||
const supplierColorGroup = getFirstSupplierColorGroup();
|
||||
getPicturesButton.disabled = true;
|
||||
getPicturesButton.textContent = "Loading pictures...";
|
||||
setButtonLabel(getPicturesButton, "Loading pictures...");
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
manufacturerId: manufacturerSelect.value,
|
||||
@@ -688,7 +739,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
|
||||
}
|
||||
finally {
|
||||
getPicturesButton.disabled = false;
|
||||
getPicturesButton.textContent = "Get pictures";
|
||||
setButtonLabel(getPicturesButton, "Get pictures");
|
||||
updateNavigationState();
|
||||
}
|
||||
}
|
||||
@@ -740,8 +791,8 @@ function renderSourceCandidates(items) {
|
||||
actions.append(status, pictures);
|
||||
header.append(name, actions);
|
||||
const rows = item.candidates.length
|
||||
? item.candidates.map(renderSourceCandidateRow)
|
||||
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")];
|
||||
? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source))
|
||||
: [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)];
|
||||
section.append(header, ...rows);
|
||||
return section;
|
||||
}));
|
||||
@@ -813,30 +864,54 @@ function clearLocalDraft() {
|
||||
}
|
||||
function updateFindSourcesButtonState() {
|
||||
const hasSources = Boolean(readLocalDraft()?.sources);
|
||||
findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources";
|
||||
setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources");
|
||||
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
|
||||
}
|
||||
function renderSourceCandidateRow(candidate) {
|
||||
const row = document.createElement("a");
|
||||
function renderSourceCandidateRow(candidate, source) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "source-row";
|
||||
row.href = candidate.url;
|
||||
row.target = "_blank";
|
||||
row.rel = "noreferrer";
|
||||
const title = document.createElement("span");
|
||||
title.className = "source-row-title";
|
||||
title.textContent = formatValue(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.textContent = "Open";
|
||||
action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action));
|
||||
row.append(title, action);
|
||||
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({
|
||||
title: label,
|
||||
url,
|
||||
});
|
||||
}, source);
|
||||
}
|
||||
function closeSourceCandidates() {
|
||||
sourceModal.hidden = true;
|
||||
@@ -845,12 +920,6 @@ function closeSourceCandidates() {
|
||||
function openSettings() {
|
||||
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() {
|
||||
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed)
|
||||
openSidebar();
|
||||
@@ -1024,6 +1093,8 @@ function refreshDatabaseStatusLabel() {
|
||||
: (transactionMode === "dry-run" ? "dry-run" : "transaction");
|
||||
databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`;
|
||||
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) {
|
||||
databaseModeNote.textContent = mode === "live"
|
||||
@@ -1205,7 +1276,7 @@ async function loadProductInfoForCurrentProduct() {
|
||||
return;
|
||||
}
|
||||
loadProductButton.disabled = true;
|
||||
loadProductButton.textContent = "Loading info...";
|
||||
setButtonLabel(loadProductButton, "Loading info...");
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
productId: currentProduct.idProduct,
|
||||
@@ -1228,7 +1299,7 @@ async function loadProductInfoForCurrentProduct() {
|
||||
setTableMessage(error.message);
|
||||
}
|
||||
finally {
|
||||
loadProductButton.textContent = "Get product info - 00";
|
||||
setButtonLabel(loadProductButton, "Get product info - 00");
|
||||
updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
+107
-36
@@ -8,6 +8,7 @@ const manufacturerSearch = document.querySelector("#manufacturerSearch");
|
||||
const manufacturerOptions = document.querySelector("#manufacturerOptions");
|
||||
const manufacturerValue = document.querySelector("#manufacturerValue");
|
||||
const languageSelect = document.querySelector("#languageSelect");
|
||||
const languageValue = document.querySelector("#languageValue");
|
||||
const recordIdElement = document.querySelector("#recordId");
|
||||
const loadProductButton = document.querySelector("#loadProductButton");
|
||||
const previousProductButton = document.querySelector("#previousProductButton");
|
||||
@@ -21,12 +22,11 @@ const suggestedTitle = document.querySelector("#suggestedTitle");
|
||||
const suggestedList = document.querySelector("#suggestedList");
|
||||
const suggestedCloseButton = document.querySelector("#suggestedCloseButton");
|
||||
const mappingState = document.querySelector("#mappingState");
|
||||
const mappingStatusText = document.querySelector("#mappingStatusText");
|
||||
const mappingList = document.querySelector("#mappingList");
|
||||
const mappingDetails = document.querySelector("#mappingDetails");
|
||||
const findSourcesButton = document.querySelector("#findSourcesButton");
|
||||
const getPicturesButton = document.querySelector("#getPicturesButton");
|
||||
const parserDrawerToggle = document.querySelector("#parserDrawerToggle");
|
||||
const parserDrawer = document.querySelector("#parserDrawer");
|
||||
const sidebarDrawer = document.querySelector("#sidebarDrawer");
|
||||
const sidebarOpenButton = document.querySelector("#sidebarOpenButton");
|
||||
const sidebarCloseButton = document.querySelector("#sidebarCloseButton");
|
||||
@@ -34,6 +34,7 @@ const sidebarOverlay = document.querySelector("#sidebarOverlay");
|
||||
const sourceModal = document.querySelector("#sourceModal");
|
||||
const sourceTitle = document.querySelector("#sourceTitle");
|
||||
const sourceList = document.querySelector("#sourceList");
|
||||
const sourceReloadButton = document.querySelector("#sourceReloadButton");
|
||||
const sourceCloseButton = document.querySelector("#sourceCloseButton");
|
||||
const settingsButton = document.querySelector("#settingsButton");
|
||||
const themeToggle = document.querySelector("#themeToggle");
|
||||
@@ -44,6 +45,7 @@ const databaseModeInputs = document.querySelectorAll('input[name="databaseMode"]
|
||||
const permissionInputs = document.querySelectorAll("#allowInsertToggle, #allowUpdateToggle, #allowDeleteToggle");
|
||||
const transactionModeInputs = document.querySelectorAll('input[name="transactionMode"]');
|
||||
const databaseStatus = document.querySelector("#databaseStatus");
|
||||
const databaseStateDot = document.querySelector("#databaseStateDot");
|
||||
const localDbFields = document.querySelector("#localDbFields");
|
||||
const localDbInputs = document.querySelectorAll("#localDbFields input");
|
||||
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 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.
|
||||
headers.set("X-Database-Mode", selectedMode === "live" ? "live" : "local");
|
||||
headers.set("X-Scrape-Database", selectedMode === "scrape" ? "1" : "0");
|
||||
headers.set("X-Local-Write-Access", localWritesEnabled ? "1" : "0");
|
||||
return nativeFetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
@@ -76,6 +85,7 @@ let loadRequestId = 0;
|
||||
let applicationMode = "dry-run";
|
||||
let databaseConnectionState = "unknown";
|
||||
const localDraftKey = "catalog-maker:current-product-draft:v2";
|
||||
const selectedManufacturerKey = "catalog-maker:selected-manufacturer";
|
||||
let localDraftProductKey = "";
|
||||
|
||||
const fieldNames = [
|
||||
@@ -149,8 +159,14 @@ renderTableHeader();
|
||||
setTableWidth();
|
||||
await loadManufacturers();
|
||||
await loadLanguages();
|
||||
languageSelect.addEventListener("change", syncLanguagePickerLabel);
|
||||
|
||||
manufacturerSelect.addEventListener("change", () => {
|
||||
if (manufacturerSelect.value) {
|
||||
localStorage.setItem(selectedManufacturerKey, manufacturerSelect.value);
|
||||
} else {
|
||||
localStorage.removeItem(selectedManufacturerKey);
|
||||
}
|
||||
syncManufacturerPickerLabel();
|
||||
closeManufacturerPicker();
|
||||
if (manufacturerSelect.value) {
|
||||
@@ -175,8 +191,8 @@ loadProductButton.disabled = true;
|
||||
loadProductButton.title = "Load existing product attribute info.";
|
||||
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
|
||||
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
|
||||
sourceReloadButton.addEventListener("click", () => findSourcesForCurrentProduct(true));
|
||||
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
|
||||
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
|
||||
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
|
||||
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
|
||||
sidebarOpenButton.addEventListener("click", openSidebar);
|
||||
@@ -301,15 +317,31 @@ async function loadManufacturers() {
|
||||
manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name));
|
||||
}
|
||||
if (data.message) manufacturerSelect.append(createOption("", data.message));
|
||||
restoreSelectedManufacturer();
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
} catch (error) {
|
||||
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
|
||||
restoreSelectedManufacturer();
|
||||
syncManufacturerPickerLabel();
|
||||
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() {
|
||||
if (manufacturerMenu.hidden) {
|
||||
manufacturerMenu.hidden = false;
|
||||
@@ -369,6 +401,15 @@ function createOption(value, label) {
|
||||
return option;
|
||||
}
|
||||
|
||||
function setButtonLabel(button, label) {
|
||||
const labelElement = button.querySelector(":scope > span");
|
||||
if (labelElement) {
|
||||
labelElement.textContent = label;
|
||||
} else {
|
||||
button.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLanguages() {
|
||||
languageSelect.replaceChildren(createOption("", "Select language"));
|
||||
try {
|
||||
@@ -384,6 +425,12 @@ async function loadLanguages() {
|
||||
} catch (error) {
|
||||
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() {
|
||||
@@ -393,8 +440,7 @@ async function loadMappingSources() {
|
||||
return;
|
||||
}
|
||||
|
||||
mappingState.textContent = "Loading mapping...";
|
||||
mappingState.className = "mapping-state";
|
||||
setMappingStatus("Loading mapping...", "is-loading");
|
||||
mappingList.replaceChildren();
|
||||
|
||||
try {
|
||||
@@ -420,10 +466,9 @@ function renderMappingSources(data) {
|
||||
const items = data.items || [];
|
||||
|
||||
if (!data.mapped || !items.length) {
|
||||
mappingState.textContent = data.message || "Mapping missing";
|
||||
mappingState.className = "mapping-state is-missing";
|
||||
setMappingStatus(data.message || "Mapping missing", "is-missing");
|
||||
mappingList.replaceChildren();
|
||||
mappingDetails.open = true;
|
||||
mappingDetails.open = false;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -431,11 +476,11 @@ function renderMappingSources(data) {
|
||||
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 ready = enabledCount > 0 && failedCount === 0;
|
||||
mappingState.textContent = ready
|
||||
const statusText = ready
|
||||
? `Sources OK ✓ (${testedCount}/${enabledCount})`
|
||||
: data.message || `Sources problem (${testedCount}/${enabledCount})`;
|
||||
mappingState.className = ready ? "mapping-state is-ready" : "mapping-state is-missing";
|
||||
mappingDetails.open = !ready || failedCount > 0;
|
||||
setMappingStatus(statusText, ready ? "is-ready" : testedCount > 0 ? "is-warning" : "is-missing");
|
||||
mappingDetails.open = false;
|
||||
|
||||
mappingList.replaceChildren(
|
||||
...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) {
|
||||
if (!item.enabled) return "off";
|
||||
if (item.health === "ok") return `${item.httpStatus || 200} OK`;
|
||||
@@ -650,14 +702,14 @@ function closeSuggestedProducts() {
|
||||
suggestedList.replaceChildren();
|
||||
}
|
||||
|
||||
async function findSourcesForCurrentProduct() {
|
||||
async function findSourcesForCurrentProduct(forceRefresh = false) {
|
||||
if (!manufacturerSelect.value || !currentProduct) {
|
||||
setTableMessage("Select manufacturer and product first.");
|
||||
return;
|
||||
}
|
||||
|
||||
const savedDraft = readLocalDraft();
|
||||
if (savedDraft?.sources) {
|
||||
if (savedDraft?.sources && !forceRefresh) {
|
||||
renderSourceCandidates(savedDraft.sources.items || []);
|
||||
updateFindSourcesButtonState();
|
||||
sourceTitle.textContent = "Source candidates (cached)";
|
||||
@@ -667,7 +719,8 @@ async function findSourcesForCurrentProduct() {
|
||||
|
||||
const supplierColorGroup = getFirstSupplierColorGroup();
|
||||
findSourcesButton.disabled = true;
|
||||
findSourcesButton.textContent = "Finding sources...";
|
||||
sourceReloadButton.disabled = true;
|
||||
setButtonLabel(findSourcesButton, "Finding sources...");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -696,6 +749,7 @@ async function findSourcesForCurrentProduct() {
|
||||
} catch (error) {
|
||||
setTableMessage(error.message || "Source search failed.");
|
||||
} finally {
|
||||
sourceReloadButton.disabled = false;
|
||||
updateFindSourcesButtonState();
|
||||
}
|
||||
}
|
||||
@@ -717,7 +771,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
|
||||
|
||||
const supplierColorGroup = getFirstSupplierColorGroup();
|
||||
getPicturesButton.disabled = true;
|
||||
getPicturesButton.textContent = "Loading pictures...";
|
||||
setButtonLabel(getPicturesButton, "Loading pictures...");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -744,7 +798,7 @@ async function getPicturesForCurrentProduct(sourceKey = "") {
|
||||
setTableMessage(error.message || "Picture lookup failed.");
|
||||
} finally {
|
||||
getPicturesButton.disabled = false;
|
||||
getPicturesButton.textContent = "Get pictures";
|
||||
setButtonLabel(getPicturesButton, "Get pictures");
|
||||
updateNavigationState();
|
||||
}
|
||||
}
|
||||
@@ -810,8 +864,8 @@ function renderSourceCandidates(items) {
|
||||
header.append(name, actions);
|
||||
|
||||
const rows = item.candidates.length
|
||||
? item.candidates.map(renderSourceCandidateRow)
|
||||
: [renderManualSearchRow(item.searchUrl, item.error || "Open search")];
|
||||
? item.candidates.map((candidate) => renderSourceCandidateRow(candidate, item.source))
|
||||
: [renderManualSearchRow(item.searchUrl, item.error || "Open search", item.source)];
|
||||
|
||||
section.append(header, ...rows);
|
||||
return section;
|
||||
@@ -892,35 +946,57 @@ function clearLocalDraft() {
|
||||
|
||||
function updateFindSourcesButtonState() {
|
||||
const hasSources = Boolean(readLocalDraft()?.sources);
|
||||
findSourcesButton.textContent = hasSources ? "Find sources ✓" : "Find sources";
|
||||
setButtonLabel(findSourcesButton, hasSources ? "Find sources ✓" : "Find sources");
|
||||
findSourcesButton.disabled = !manufacturerSelect.value || !currentProduct;
|
||||
}
|
||||
|
||||
function renderSourceCandidateRow(candidate) {
|
||||
const row = document.createElement("a");
|
||||
function renderSourceCandidateRow(candidate, source) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "source-row";
|
||||
row.href = candidate.url;
|
||||
row.target = "_blank";
|
||||
row.rel = "noreferrer";
|
||||
|
||||
const title = document.createElement("span");
|
||||
title.className = "source-row-title";
|
||||
title.textContent = formatValue(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.textContent = "Open";
|
||||
action.addEventListener("click", () => openSourceCandidate(candidate.url, source, action));
|
||||
|
||||
row.append(title, action);
|
||||
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({
|
||||
title: label,
|
||||
url,
|
||||
});
|
||||
}, source);
|
||||
}
|
||||
|
||||
function closeSourceCandidates() {
|
||||
@@ -932,13 +1008,6 @@ function openSettings() {
|
||||
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() {
|
||||
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) openSidebar();
|
||||
else closeSidebar();
|
||||
@@ -1121,6 +1190,8 @@ function refreshDatabaseStatusLabel() {
|
||||
: (transactionMode === "dry-run" ? "dry-run" : "transaction");
|
||||
databaseStatus.textContent = `${dbLabel} / ${connectionLabel} / ${accessLabel} / ${executionLabel}`;
|
||||
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) {
|
||||
@@ -1320,7 +1391,7 @@ async function loadProductInfoForCurrentProduct() {
|
||||
}
|
||||
|
||||
loadProductButton.disabled = true;
|
||||
loadProductButton.textContent = "Loading info...";
|
||||
setButtonLabel(loadProductButton, "Loading info...");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
@@ -1346,7 +1417,7 @@ async function loadProductInfoForCurrentProduct() {
|
||||
} catch (error) {
|
||||
setTableMessage(error.message);
|
||||
} finally {
|
||||
loadProductButton.textContent = "Get product info - 00";
|
||||
setButtonLabel(loadProductButton, "Get product info - 00");
|
||||
updateNavigationState();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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="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="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>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.9 KiB |
+36
-26
@@ -30,12 +30,6 @@
|
||||
<button class="arrow-button arrow-button-next" id="nextProductButton" type="button" title="Next product" aria-label="Next product"></button>
|
||||
</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="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>
|
||||
@@ -57,8 +51,11 @@
|
||||
</div>
|
||||
|
||||
<div class="select-control">
|
||||
<svg class="sidebar-icon select-icon" aria-hidden="true"><use href="/icons.svg#globe"></use></svg>
|
||||
<select class="input-like" id="languageSelect" aria-label="Language">
|
||||
<div class="select-display" aria-hidden="true">
|
||||
<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>
|
||||
</select>
|
||||
<span class="select-chevron" aria-hidden="true"></span>
|
||||
@@ -66,7 +63,7 @@
|
||||
|
||||
<div class="manufacturer-picker" id="manufacturerPicker">
|
||||
<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 class="select-chevron picker-chevron" aria-hidden="true"></span>
|
||||
</button>
|
||||
@@ -75,29 +72,39 @@
|
||||
<div class="manufacturer-options" id="manufacturerOptions" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<select class="input-like native-manufacturer-select" id="manufacturerSelect" aria-label="Manufacturer" hidden>
|
||||
<option value="">Select manufacturer</option>
|
||||
</select>
|
||||
<div class="native-manufacturer-host" hidden>
|
||||
<select class="input-like native-manufacturer-select" id="manufacturerSelect" aria-label="Manufacturer">
|
||||
<option value="">Select manufacturer</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mapping-box" id="mappingBox">
|
||||
<div class="mapping-box sidebar-mapping-box" id="mappingBox">
|
||||
<details class="mapping-details" id="mappingDetails">
|
||||
<summary>
|
||||
<span class="mapping-state is-missing" id="mappingState">Mapping not loaded</span>
|
||||
<summary class="mapping-summary">
|
||||
<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>
|
||||
</summary>
|
||||
<div class="mapping-list" id="mappingList"></div>
|
||||
<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>
|
||||
</details>
|
||||
</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="app-status">
|
||||
<span class="safe-mode" id="databaseStatus">DB read-only</span>
|
||||
</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" 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>
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
@@ -129,7 +136,7 @@
|
||||
<section class="suggested-dialog" aria-labelledby="suggestedTitle">
|
||||
<header class="suggested-header">
|
||||
<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>
|
||||
<div class="suggested-list" id="suggestedList"></div>
|
||||
</section>
|
||||
@@ -139,7 +146,10 @@
|
||||
<section class="source-dialog" aria-labelledby="sourceTitle">
|
||||
<header class="suggested-header">
|
||||
<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>
|
||||
<div class="source-list" id="sourceList"></div>
|
||||
</section>
|
||||
@@ -149,7 +159,7 @@
|
||||
<section class="settings-dialog" aria-labelledby="settingsTitle">
|
||||
<header class="suggested-header">
|
||||
<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>
|
||||
<div class="settings-content">
|
||||
<fieldset class="settings-group">
|
||||
@@ -187,7 +197,7 @@
|
||||
<span>Password</span>
|
||||
<input id="localDbPassword" type="text" autocomplete="off" />
|
||||
</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>
|
||||
</div>
|
||||
<p class="settings-note" id="databaseModeNote">Local 9bplus database selected.</p>
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+4
-1
@@ -30,7 +30,10 @@ export function loadConfig({ configPath = "config/local.json", dryRun } = {}) {
|
||||
database: {
|
||||
driver: String(env.DB_DRIVER || config.database?.driver || "endpoint").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",
|
||||
port: Number(env.DB_PORT || config.database?.port || 3306),
|
||||
name: env.DB_NAME || config.database?.name || "catalog_maker_test",
|
||||
|
||||
@@ -40,8 +40,8 @@ export async function executeMariaDbWrite(config, sql) {
|
||||
if (config.database.mode !== "local") {
|
||||
throw new Error("MariaDB writes are blocked outside Local DB mode.");
|
||||
}
|
||||
if (!config.database.allowWrites) {
|
||||
throw new Error("MariaDB writes are disabled. Enable local database writes in settings first.");
|
||||
if (!config.database.allowLocalWrites) {
|
||||
throw new Error("Local MariaDB writes are disabled. Enable local database writes in Settings first.");
|
||||
}
|
||||
|
||||
const connection = await getPool(config).getConnection();
|
||||
|
||||
+19
-1
@@ -15,6 +15,7 @@ import {
|
||||
loadProductByEan,
|
||||
loadProductInfo,
|
||||
getProductPictures,
|
||||
openProductSource,
|
||||
} from "./services/catalog-products.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -122,6 +123,18 @@ const server = http.createServer(async (request, response) => {
|
||||
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") {
|
||||
await sendJson(
|
||||
response,
|
||||
@@ -197,7 +210,9 @@ function getStatus() {
|
||||
driver: config.database.driver,
|
||||
mode: config.database.mode,
|
||||
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),
|
||||
workbookExists: true,
|
||||
@@ -230,6 +245,9 @@ function getRequestConfig(request) {
|
||||
database: {
|
||||
...config.database,
|
||||
mode: selectedMode,
|
||||
allowLocalWrites: selectedMode === "local"
|
||||
&& config.database.allowLocalWrites
|
||||
&& request.headers["x-local-write-access"] === "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
// @ts-nocheck
|
||||
import { chromium } from "playwright";
|
||||
import fs from "node:fs";
|
||||
import { chromium, firefox } from "playwright";
|
||||
|
||||
const visibleBrowsers = new Set();
|
||||
|
||||
export async function openInBrowser(url) {
|
||||
const browser = await chromium.launch({ headless: false });
|
||||
export async function openInBrowser(url, { engine = "chromium" } = {}) {
|
||||
const browser = await launchBrowser(engine, false);
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
|
||||
visibleBrowsers.add(browser);
|
||||
return { browser, page };
|
||||
}
|
||||
|
||||
export async function searchInBrowser({ url, headless = true, waitAfterLoadMs = 3000 }) {
|
||||
const browser = await chromium.launch({ headless });
|
||||
export async function searchInBrowser({ url, engine = "chromium", headless = true, waitAfterLoadMs = 3000 }) {
|
||||
const browser = await launchBrowser(engine, headless);
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
|
||||
try {
|
||||
@@ -44,7 +45,33 @@ export async function searchInBrowser({ url, headless = true, waitAfterLoadMs =
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
|
||||
export async function loadManufacturerCatalogState(config, { manufacturerId, position = 1 }) {
|
||||
const refreshResults = [];
|
||||
for (const sql of prepareManufacturerCatalogSqls({ manufacturerId })) {
|
||||
refreshResults.push(
|
||||
await executeEndpointWrite(config, sql, {
|
||||
operation: "catalog-maker-refresh-manufacturer",
|
||||
}),
|
||||
);
|
||||
if (config.database?.mode === "local" && config.database.allowLocalWrites) {
|
||||
for (const sql of prepareManufacturerCatalogSqls({ manufacturerId })) {
|
||||
refreshResults.push(
|
||||
await executeEndpointWrite(config, sql, {
|
||||
operation: "catalog-maker-refresh-manufacturer",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const productResult = await queryEndpoint(
|
||||
@@ -49,6 +51,7 @@ export async function loadManufacturerCatalogState(config, { manufacturerId, pos
|
||||
refresh: {
|
||||
operation: "catalog-maker-refresh-manufacturer",
|
||||
statements: refreshResults.length,
|
||||
skipped: refreshResults.length === 0,
|
||||
},
|
||||
product: normalizeProduct(product),
|
||||
combinations: combinationsResult.items.map(normalizeCombination),
|
||||
@@ -182,7 +185,7 @@ export async function findProductSources(
|
||||
const candidate = item.candidates?.[0];
|
||||
if (candidate?.url) {
|
||||
try {
|
||||
await openInBrowser(candidate.url);
|
||||
await openInBrowser(candidate.url, { engine: item.source.browserEngine });
|
||||
} catch {
|
||||
// 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 } = {}) {
|
||||
const searchAttempts = buildAdapterSearchUrls(source, product);
|
||||
let lastSearchUrl = searchAttempts[0]?.url || normalizeSourceUrl(source.url);
|
||||
@@ -257,8 +285,9 @@ async function findSourceCandidates(source, product, { includePictures = false }
|
||||
|
||||
try {
|
||||
const result = await searchInBrowser({
|
||||
url: attempt.url,
|
||||
headless: source.browserHeadless,
|
||||
url: attempt.url,
|
||||
engine: source.browserEngine,
|
||||
headless: source.browserHeadless,
|
||||
waitAfterLoadMs: source.waitAfterLoadMs,
|
||||
});
|
||||
|
||||
@@ -289,7 +318,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
await openInBrowser(attempt.url);
|
||||
await openInBrowser(attempt.url, { engine: source.browserEngine });
|
||||
} catch {
|
||||
// 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;
|
||||
try {
|
||||
const response = await scrapePage(attempt.url, {
|
||||
engine: source.browserEngine,
|
||||
waitAfterLoadMs: source.waitAfterLoadMs,
|
||||
});
|
||||
const html = response.pageSource;
|
||||
@@ -327,7 +357,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
|
||||
if (candidates.length) {
|
||||
let verification = null;
|
||||
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;
|
||||
if (verification.matchedUrl) candidates[0].url = verification.matchedUrl;
|
||||
if (includePictures && verification.pictures?.length) {
|
||||
@@ -338,7 +368,7 @@ async function findSourceCandidates(source, product, { includePictures = false }
|
||||
if (includePictures) {
|
||||
for (const candidate of candidates) {
|
||||
if (source.key === "hudy" && candidate.url) {
|
||||
candidate.pictures = await extractPicturesFromPage(candidate.url);
|
||||
candidate.pictures = await extractPicturesFromPage(candidate.url, source.browserEngine);
|
||||
} else {
|
||||
candidate.pictures = extractPictureUrls(html, response.url || attempt.url);
|
||||
}
|
||||
@@ -580,7 +610,7 @@ function findColorVariantUrl(html, pageUrl, color) {
|
||||
return "";
|
||||
}
|
||||
|
||||
async function verifyHudyVariant(url, product) {
|
||||
async function verifyHudyVariant(url, product, browserEngine = "chromium") {
|
||||
try {
|
||||
const eanGroup = Array.isArray(product.eanGroup)
|
||||
? product.eanGroup
|
||||
@@ -598,7 +628,7 @@ async function verifyHudyVariant(url, product) {
|
||||
if (visited.has(page.url)) continue;
|
||||
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;
|
||||
lastStatus = response.status;
|
||||
const pageEans = extractPageEans(html);
|
||||
@@ -700,9 +730,9 @@ function extractPictureUrls(html, pageUrl) {
|
||||
return urls.slice(0, 12);
|
||||
}
|
||||
|
||||
async function extractPicturesFromPage(url) {
|
||||
async function extractPicturesFromPage(url, browserEngine = "chromium") {
|
||||
try {
|
||||
const response = await scrapePage(url, { waitAfterLoadMs: 750 });
|
||||
const response = await scrapePage(url, { engine: browserEngine, waitAfterLoadMs: 750 });
|
||||
return extractHudyGalleryPictureUrls(response.pageSource, response.url || url);
|
||||
} catch {
|
||||
return [];
|
||||
@@ -855,16 +885,20 @@ async function checkMappingSourceUrls(items) {
|
||||
|
||||
return {
|
||||
...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 {
|
||||
const target = normalizeSourceUrl(url);
|
||||
const response = await scrapePage(target, { waitAfterLoadMs: 250 });
|
||||
const response = await scrapePage(target, {
|
||||
engine: browserEngine,
|
||||
headless,
|
||||
waitAfterLoadMs: 250,
|
||||
});
|
||||
|
||||
return {
|
||||
health: response.ok ? "ok" : "warning",
|
||||
|
||||
@@ -93,7 +93,6 @@ export function productToDoByManufacturerSql({ manufacturerId, position = 1 }) {
|
||||
tr.total,
|
||||
p.id_product,
|
||||
p.price,
|
||||
p.id_supplier,
|
||||
(
|
||||
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
|
||||
FROM ps_image i
|
||||
@@ -155,7 +154,6 @@ export function productByEanSql({ manufacturerId, ean }) {
|
||||
tr.total,
|
||||
p.id_product,
|
||||
p.price,
|
||||
p.id_supplier,
|
||||
(
|
||||
SELECT CONCAT('https://www.9b-plus.com/', i.id_image, '-home_default/', pl.link_rewrite, '.jpg')
|
||||
FROM ps_image i
|
||||
|
||||
+42
-18
@@ -58,10 +58,10 @@
|
||||
.sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
|
||||
.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 .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 .drawer-chevron { @apply 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 absolute right-2 inline-flex h-2 w-2 border-b-2 border-r-2 text-[0px] leading-none; }
|
||||
.drawer-trigger .drawer-chevron { @apply rotate-45 text-slate-300; }
|
||||
.drawer-trigger[aria-expanded="true"] .drawer-chevron { @apply rotate-[225deg]; }
|
||||
.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; }
|
||||
@@ -71,6 +71,11 @@
|
||||
.action-button .sidebar-icon { @apply text-slate-400; }
|
||||
.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; }
|
||||
.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; }
|
||||
#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; }
|
||||
@@ -90,13 +95,15 @@
|
||||
.arrow-button:disabled, .action-button:disabled { @apply cursor-not-allowed opacity-50; }
|
||||
.input-like { @apply h-7 px-1 text-[11px]; }
|
||||
.select-control { @apply relative w-full; }
|
||||
.select-control .input-like { @apply appearance-none pr-7 pl-9 text-center; }
|
||||
.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-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; }
|
||||
.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-display span { @apply min-w-0 max-w-[calc(100%-3rem)] truncate; }
|
||||
.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; }
|
||||
.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 .select-icon { @apply left-3; }
|
||||
.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 > span:not(.select-chevron) { @apply min-w-0 truncate text-center; }
|
||||
.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; }
|
||||
@@ -112,14 +119,24 @@
|
||||
.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 img { @apply h-full w-full object-contain; }
|
||||
.mapping-box { @apply 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; }
|
||||
.mapping-box { @apply relative rounded border border-slate-500 bg-catalog-panel-soft; }
|
||||
.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-toggle::before { content: "›"; @apply inline-block text-center text-lg leading-none text-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-toggle::before { content: ""; @apply block h-2 w-2 rotate-45 border-b-2 border-r-2 border-slate-100; }
|
||||
.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.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[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; }
|
||||
.source-dialog-actions { @apply flex items-center gap-1.5; }
|
||||
.suggested-dialog { @apply max-w-2xl; }
|
||||
.source-dialog { @apply max-w-3xl; }
|
||||
.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 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; }
|
||||
.settings-content { @apply grid gap-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) .drawer-trigger .drawer-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-open-button,
|
||||
html:not(.dark) .drawer-trigger,
|
||||
@@ -264,8 +284,12 @@
|
||||
html:not(.dark) #scanInput { @apply bg-slate-200 placeholder:text-slate-500; }
|
||||
html:not(.dark) .picture-slot,
|
||||
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) .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:hover { @apply border-slate-400 bg-slate-200; }
|
||||
html.contrast-light-high:not(.dark) .control-panel { @apply bg-slate-200 text-slate-900; }
|
||||
|
||||
Reference in New Issue
Block a user