Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9b7b2f3c2e | ||
|
|
38dfb9fd54 | ||
|
|
1076c3c040 | ||
|
|
bf2d86329b | ||
|
|
a1e3f537e3 | ||
|
|
1914c7d141 | ||
|
|
902f1927a2 | ||
|
|
f0bd62d93e | ||
|
|
931be30eba | ||
|
|
67c0a37e2a | ||
|
|
1963773299 |
@@ -9,6 +9,7 @@ tmp/
|
||||
*.log
|
||||
*.sql
|
||||
*.sql.gz
|
||||
!sql/local-scrape-db.sql
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
|
||||
- Run `npm run css:build` after styling changes; `public/styles.css` is generated output.
|
||||
- Prefer Tailwind utility classes and Tailwind-style component patterns for buttons, panels, dialogs and responsive layouts.
|
||||
- Whenever a reusable pattern appears, prefer Tailwind component patterns (`@layer components` with `@apply`) over handwritten legacy CSS; keep one-off layout details as utility classes where practical.
|
||||
- Whenever possible, use Tailwind UI / Tailwind Plus application components and patterns for shells, multi-column layouts, sidebars, drawers, dialogs, navigation and forms; adapt them to this project's dense catalog workflow instead of inventing a separate visual pattern.
|
||||
- Multi-column panels must use a shared collapse pattern: collapsing a panel removes its grid track, lets the adjacent workspace expand, preserves an accessible expand/collapse control, and stacks predictably on mobile.
|
||||
- When the left sidebar is closed on desktop, its open control keeps a dedicated narrow rail; it must not be positioned inside the secondary product-details panel. On mobile, the rail collapses and the control may overlay the stacked layout.
|
||||
- Use Playwright to inspect the rendered result after every visual change at desktop and narrow viewport sizes.
|
||||
|
||||
## TypeScript workflow
|
||||
@@ -63,15 +66,28 @@ This project replaces the Excel/VBA workflow named `Catalog maker - 20` with a N
|
||||
|
||||
## Database architecture
|
||||
|
||||
There are two selectable data sources:
|
||||
There are three selectable data sources:
|
||||
|
||||
### Live DB
|
||||
### Local 9bplus DB
|
||||
|
||||
- Uses the imported local MariaDB database named `9bplus`.
|
||||
- This is the only database allowed to receive explicit catalog writes.
|
||||
- Writes remain disabled unless the user enables the local permission switches.
|
||||
|
||||
### Live 9bplus DB
|
||||
|
||||
- Uses the existing 9b-plus API endpoint configured in `config/local.json`.
|
||||
- Credentials are secret and must stay server-side.
|
||||
- Must remain read-only.
|
||||
|
||||
### Local DB
|
||||
### Local scrape DB
|
||||
|
||||
- Uses the separate local MariaDB database named `catalog_scrape`.
|
||||
- Stores scraper source configuration, scrape runs, discovered products and assets.
|
||||
- It must never be used as a fallback for catalog reads. When selected in the UI, existing catalog reads continue using Local 9bplus DB until dedicated scrape-storage endpoints are added.
|
||||
- The schema is versioned in `sql/local-scrape-db.sql` and can be created with `npm run db:create-scrape`.
|
||||
|
||||
### Local MariaDB connection
|
||||
|
||||
- MariaDB is installed locally as service `MariaDB`.
|
||||
- Host: `localhost` / `127.0.0.1`.
|
||||
@@ -84,12 +100,13 @@ There are two selectable data sources:
|
||||
|
||||
## Database safety behavior
|
||||
|
||||
- The UI has `Local DB` and `Live 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## Important current files
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"allowWrites": false,
|
||||
"host": "127.0.0.1",
|
||||
"port": 3306,
|
||||
"name": "catalog_maker_test",
|
||||
"name": "9bplus",
|
||||
"scrapeName": "catalog_scrape",
|
||||
"user": "catalog_maker",
|
||||
"password": "",
|
||||
"connectionLimit": 5
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "tsx src/cli.ts",
|
||||
"dev": "tsx src/server.ts",
|
||||
"catalog:dry-run": "tsx src/cli.ts catalog-maker --dry-run",
|
||||
"db:create-scrape": "tsx scripts/create-local-scrape-db.ts",
|
||||
"test": "tsx --test test/*.test.ts",
|
||||
"test:ui": "playwright test",
|
||||
"test:e2e": "playwright test tests/ui/database.e2e.spec.ts",
|
||||
|
||||
+185
-24
@@ -2,13 +2,18 @@
|
||||
const fieldGrid = document.querySelector("#fieldGrid");
|
||||
const variantHeader = document.querySelector("#variantHeader");
|
||||
const manufacturerSelect = document.querySelector("#manufacturerSelect");
|
||||
const manufacturerTrigger = document.querySelector("#manufacturerTrigger");
|
||||
const manufacturerMenu = document.querySelector("#manufacturerMenu");
|
||||
const manufacturerSearch = document.querySelector("#manufacturerSearch");
|
||||
const manufacturerOptions = document.querySelector("#manufacturerOptions");
|
||||
const manufacturerValue = document.querySelector("#manufacturerValue");
|
||||
const languageSelect = document.querySelector("#languageSelect");
|
||||
const recordIdElement = document.querySelector("#recordId");
|
||||
const loadProductButton = document.querySelector("#loadProductButton");
|
||||
const previousProductButton = document.querySelector("#previousProductButton");
|
||||
const nextProductButton = document.querySelector("#nextProductButton");
|
||||
const tableBody = document.querySelector(".variant-table tbody");
|
||||
const variantTable = document.querySelector(".variant-table");
|
||||
const tableBody = document.querySelector("#variantRows");
|
||||
const variantTable = document.querySelector(".variant-grid");
|
||||
const pictureSlot = document.querySelector("#pictureSlot");
|
||||
const scanInput = document.querySelector("#scanInput");
|
||||
const suggestedModal = document.querySelector("#suggestedModal");
|
||||
@@ -22,6 +27,10 @@ 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");
|
||||
const sidebarOverlay = document.querySelector("#sidebarOverlay");
|
||||
const sourceModal = document.querySelector("#sourceModal");
|
||||
const sourceTitle = document.querySelector("#sourceTitle");
|
||||
const sourceList = document.querySelector("#sourceList");
|
||||
@@ -39,13 +48,22 @@ const localDbFields = document.querySelector("#localDbFields");
|
||||
const localDbInputs = document.querySelectorAll("#localDbFields input");
|
||||
const testLocalDbButton = document.querySelector("#testLocalDbButton");
|
||||
const localDbTestResult = document.querySelector("#localDbTestResult");
|
||||
const lightContrastSelect = document.querySelector("#lightContrastSelect");
|
||||
const darkContrastSelect = document.querySelector("#darkContrastSelect");
|
||||
const catalogScreen = document.querySelector(".catalog-screen");
|
||||
const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle");
|
||||
const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle");
|
||||
let sidebarUserClosed = false;
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init = {}) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
if (!url.startsWith("/api/"))
|
||||
return nativeFetch(input, init);
|
||||
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
|
||||
headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local");
|
||||
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
|
||||
// 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");
|
||||
return nativeFetch(input, { ...init, headers });
|
||||
};
|
||||
let currentPosition = 1;
|
||||
@@ -119,12 +137,15 @@ const productFieldMap = {
|
||||
};
|
||||
await loadStatus();
|
||||
loadTheme();
|
||||
loadAppearance();
|
||||
renderFields();
|
||||
renderTableHeader();
|
||||
setTableWidth();
|
||||
await loadManufacturers();
|
||||
await loadLanguages();
|
||||
manufacturerSelect.addEventListener("change", () => {
|
||||
syncManufacturerPickerLabel();
|
||||
closeManufacturerPicker();
|
||||
if (manufacturerSelect.value) {
|
||||
loadMappingSources();
|
||||
loadProductForSelectedManufacturer(1);
|
||||
@@ -134,12 +155,31 @@ manufacturerSelect.addEventListener("change", () => {
|
||||
clearProductState("Select manufacturer to load data.");
|
||||
}
|
||||
});
|
||||
manufacturerTrigger.addEventListener("click", toggleManufacturerPicker);
|
||||
manufacturerSearch.addEventListener("input", renderManufacturerOptions);
|
||||
manufacturerSearch.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape")
|
||||
closeManufacturerPicker();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("#manufacturerPicker"))
|
||||
closeManufacturerPicker();
|
||||
});
|
||||
loadProductButton.disabled = true;
|
||||
loadProductButton.title = "Load existing product attribute info.";
|
||||
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
|
||||
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
|
||||
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
|
||||
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
|
||||
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
|
||||
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
|
||||
sidebarOpenButton.addEventListener("click", openSidebar);
|
||||
sidebarCloseButton.addEventListener("click", closeSidebar);
|
||||
sidebarOverlay.addEventListener("click", closeSidebar);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape")
|
||||
closeSidebar();
|
||||
});
|
||||
previousProductButton.addEventListener("click", () => {
|
||||
if (!manufacturerSelect.value || currentPosition <= 1)
|
||||
return;
|
||||
@@ -182,7 +222,10 @@ for (const input of transactionModeInputs)
|
||||
for (const input of localDbInputs)
|
||||
input.addEventListener("change", saveLocalDbSettings);
|
||||
testLocalDbButton.addEventListener("click", testLocalDbConnection);
|
||||
lightContrastSelect.addEventListener("change", saveAppearance);
|
||||
darkContrastSelect.addEventListener("change", saveAppearance);
|
||||
loadDatabaseMode();
|
||||
syncSidebarForViewport();
|
||||
setTimeout(() => {
|
||||
if (manufacturerSelect.value) {
|
||||
loadMappingSources();
|
||||
@@ -219,17 +262,19 @@ function renderFields() {
|
||||
}
|
||||
function renderTableHeader() {
|
||||
variantHeader.replaceChildren(...variantColumns.map((column) => {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = column.label;
|
||||
th.dataset.column = column.key;
|
||||
th.style.width = `${column.width}px`;
|
||||
th.style.textAlign = column.align || "";
|
||||
return th;
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "variant-header-cell";
|
||||
cell.setAttribute("role", "columnheader");
|
||||
cell.textContent = column.label;
|
||||
cell.dataset.column = column.key;
|
||||
cell.style.textAlign = column.align || "";
|
||||
return cell;
|
||||
}));
|
||||
}
|
||||
function setTableWidth() {
|
||||
const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0);
|
||||
variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`;
|
||||
variantTable.style.setProperty("--variant-min-width", `${Math.max(totalWidth, 820)}px`);
|
||||
variantTable.style.setProperty("--variant-columns", variantColumns.map((column) => `${column.width}px`).join(" "));
|
||||
}
|
||||
async function loadManufacturers() {
|
||||
manufacturerSelect.replaceChildren(createOption("", "Select manufacturer"));
|
||||
@@ -243,9 +288,59 @@ async function loadManufacturers() {
|
||||
}
|
||||
if (data.message)
|
||||
manufacturerSelect.append(createOption("", data.message));
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
}
|
||||
catch (error) {
|
||||
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
}
|
||||
}
|
||||
function toggleManufacturerPicker() {
|
||||
if (manufacturerMenu.hidden) {
|
||||
manufacturerMenu.hidden = false;
|
||||
manufacturerTrigger.setAttribute("aria-expanded", "true");
|
||||
manufacturerSearch.value = "";
|
||||
renderManufacturerOptions();
|
||||
if (manufacturerSelect.options.length > 11)
|
||||
manufacturerSearch.focus();
|
||||
}
|
||||
else {
|
||||
closeManufacturerPicker();
|
||||
}
|
||||
}
|
||||
function closeManufacturerPicker() {
|
||||
manufacturerMenu.hidden = true;
|
||||
manufacturerTrigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
function syncManufacturerPickerLabel() {
|
||||
const option = manufacturerSelect.selectedOptions[0];
|
||||
manufacturerValue.textContent = option?.textContent || "Select manufacturer";
|
||||
}
|
||||
function renderManufacturerOptions() {
|
||||
const options = [...manufacturerSelect.options].filter((option) => option.value);
|
||||
const query = manufacturerSearch.value.trim().toLocaleLowerCase();
|
||||
const filtered = options.filter((option) => option.textContent.toLocaleLowerCase().includes(query));
|
||||
const visible = filtered.slice(0, 10);
|
||||
manufacturerOptions.replaceChildren(...visible.map((option) => {
|
||||
const item = document.createElement("button");
|
||||
item.className = "manufacturer-option";
|
||||
item.type = "button";
|
||||
item.setAttribute("role", "option");
|
||||
item.textContent = option.textContent;
|
||||
item.setAttribute("aria-selected", String(option.value === manufacturerSelect.value));
|
||||
item.addEventListener("click", () => {
|
||||
manufacturerSelect.value = option.value;
|
||||
manufacturerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return item;
|
||||
}));
|
||||
if (!visible.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "manufacturer-empty";
|
||||
empty.textContent = "No manufacturer found.";
|
||||
manufacturerOptions.append(empty);
|
||||
}
|
||||
}
|
||||
function createOption(value, label) {
|
||||
@@ -756,12 +851,69 @@ function toggleParserDrawer() {
|
||||
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();
|
||||
}
|
||||
function openSidebar() {
|
||||
sidebarUserClosed = false;
|
||||
sidebarDrawer.classList.add("is-open");
|
||||
sidebarOverlay.hidden = false;
|
||||
sidebarOpenButton.hidden = true;
|
||||
sidebarOpenButton.setAttribute("aria-expanded", "true");
|
||||
sidebarDrawer.setAttribute("aria-hidden", "false");
|
||||
catalogScreen.classList.remove("sidebar-collapsed");
|
||||
}
|
||||
function closeSidebar() {
|
||||
sidebarUserClosed = true;
|
||||
sidebarDrawer.classList.remove("is-open");
|
||||
sidebarOverlay.hidden = true;
|
||||
sidebarOpenButton.hidden = false;
|
||||
sidebarOpenButton.setAttribute("aria-expanded", "false");
|
||||
sidebarDrawer.setAttribute("aria-hidden", "true");
|
||||
catalogScreen.classList.add("sidebar-collapsed");
|
||||
}
|
||||
function toggleSecondaryPanel() {
|
||||
const isCollapsed = catalogScreen.classList.toggle("secondary-collapsed");
|
||||
secondaryPanelToggle.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
secondaryPanelToggle.setAttribute("aria-label", isCollapsed ? "Expand product details" : "Collapse product details");
|
||||
secondaryPanelToggle.title = isCollapsed ? "Expand product details" : "Collapse product details";
|
||||
secondaryPanelToggle.textContent = isCollapsed ? "›" : "‹";
|
||||
}
|
||||
function toggleMainWorkspace() {
|
||||
const isCollapsed = catalogScreen.classList.toggle("workspace-collapsed");
|
||||
mainWorkspaceToggle.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
mainWorkspaceToggle.setAttribute("aria-label", isCollapsed ? "Expand main workspace" : "Collapse main workspace");
|
||||
mainWorkspaceToggle.title = isCollapsed ? "Expand main workspace" : "Collapse main workspace";
|
||||
mainWorkspaceToggle.textContent = isCollapsed ? "‹" : "›";
|
||||
}
|
||||
function loadTheme() {
|
||||
const isDark = localStorage.getItem("catalog-maker:theme") === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
themeToggle.querySelector("span").textContent = isDark ? "Light theme" : "Dark theme";
|
||||
themeToggle.setAttribute("aria-pressed", String(isDark));
|
||||
}
|
||||
function loadAppearance() {
|
||||
const settings = JSON.parse(localStorage.getItem("catalog-maker:appearance") || "{}");
|
||||
lightContrastSelect.value = settings.lightContrast || "medium";
|
||||
darkContrastSelect.value = settings.darkContrast || "medium";
|
||||
applyAppearance();
|
||||
}
|
||||
function saveAppearance() {
|
||||
localStorage.setItem("catalog-maker:appearance", JSON.stringify({
|
||||
lightContrast: lightContrastSelect.value,
|
||||
darkContrast: darkContrastSelect.value,
|
||||
}));
|
||||
applyAppearance();
|
||||
}
|
||||
function applyAppearance() {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("contrast-light-low", "contrast-light-medium", "contrast-light-high", "contrast-dark-low", "contrast-dark-medium", "contrast-dark-high");
|
||||
root.classList.add(`contrast-light-${lightContrastSelect.value || "medium"}`);
|
||||
root.classList.add(`contrast-dark-${darkContrastSelect.value || "medium"}`);
|
||||
}
|
||||
function toggleTheme() {
|
||||
const isDark = !document.documentElement.classList.contains("dark");
|
||||
localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light");
|
||||
@@ -793,7 +945,7 @@ function loadDatabaseMode() {
|
||||
refreshDatabaseStatusLabel();
|
||||
}
|
||||
function saveDatabaseMode(event) {
|
||||
const mode = event.target.value === "live" ? "live" : "local";
|
||||
const mode = ["local", "live", "scrape"].includes(event.target.value) ? event.target.value : "local";
|
||||
localStorage.setItem("catalog-maker:database-mode", mode);
|
||||
syncDatabasePermissions(mode);
|
||||
syncLocalDbFields(mode);
|
||||
@@ -840,10 +992,10 @@ function syncLocalDbFields(mode) {
|
||||
localDbFields.hidden = mode !== "local";
|
||||
}
|
||||
function syncDatabasePermissions(mode) {
|
||||
const isLive = mode === "live";
|
||||
const isReadOnly = mode === "live" || mode === "scrape";
|
||||
for (const input of permissionInputs) {
|
||||
input.disabled = isLive;
|
||||
if (isLive)
|
||||
input.disabled = isReadOnly;
|
||||
if (isReadOnly)
|
||||
input.checked = false;
|
||||
}
|
||||
}
|
||||
@@ -861,7 +1013,7 @@ function refreshDatabaseStatusLabel() {
|
||||
const transactionMode = localStorage.getItem("catalog-maker:transaction-mode") || "transaction";
|
||||
const permissions = JSON.parse(localStorage.getItem("catalog-maker:db-permissions") || "{}");
|
||||
const writesEnabled = mode === "local" && (permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle);
|
||||
const dbLabel = mode === "live" ? "Live DB" : "Local DB";
|
||||
const dbLabel = mode === "live" ? "Live 9bplus DB" : mode === "scrape" ? "Local scrape DB" : "Local 9bplus DB";
|
||||
const configuredDriver = databaseStatus.dataset.driver;
|
||||
const connectionError = (mode === "local" && configuredDriver !== "mariadb")
|
||||
|| (mode === "live" && configuredDriver !== "endpoint");
|
||||
@@ -875,8 +1027,10 @@ function refreshDatabaseStatusLabel() {
|
||||
}
|
||||
function renderDatabaseModeNote(mode) {
|
||||
databaseModeNote.textContent = mode === "live"
|
||||
? "Live DB selected for preparation. The current project still stays read-only."
|
||||
: "Local test database selected.";
|
||||
? "Live 9bplus DB selected for read-only data."
|
||||
: mode === "scrape"
|
||||
? "Local scrape DB selected for scraper settings and scraped data. Catalog reads stay on Local 9bplus DB."
|
||||
: "Local 9bplus DB selected.";
|
||||
}
|
||||
function renderProduct(product) {
|
||||
const productKey = `${manufacturerSelect.value}:${product.idProductCatalog || product.supplierReference || product.name || ""}`;
|
||||
@@ -907,18 +1061,22 @@ function renderCombinations(combinations) {
|
||||
return;
|
||||
}
|
||||
tableBody.replaceChildren(...combinations.map((item) => {
|
||||
const row = document.createElement("tr");
|
||||
const row = document.createElement("div");
|
||||
row.className = "variant-row";
|
||||
row.setAttribute("role", "row");
|
||||
row.append(...variantColumns.map((column) => {
|
||||
const value = item[column.key];
|
||||
const text = formatTableValue(value, column);
|
||||
const cell = document.createElement("td");
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "variant-cell";
|
||||
cell.setAttribute("role", "cell");
|
||||
cell.textContent = text;
|
||||
cell.title = text;
|
||||
cell.dataset.column = column.key;
|
||||
cell.style.width = `${column.width}px`;
|
||||
cell.dataset.label = column.label;
|
||||
cell.style.textAlign = column.align || "";
|
||||
if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) {
|
||||
cell.className = "problem-ean";
|
||||
cell.classList.add("problem-ean");
|
||||
}
|
||||
return cell;
|
||||
}));
|
||||
@@ -940,10 +1098,13 @@ function clearProductState(message) {
|
||||
updateNavigationState();
|
||||
}
|
||||
function setTableMessage(message) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
const row = document.createElement("div");
|
||||
row.className = "variant-row variant-message-row";
|
||||
row.setAttribute("role", "row");
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "empty-row";
|
||||
cell.colSpan = variantColumns.length;
|
||||
cell.setAttribute("role", "cell");
|
||||
cell.style.gridColumn = "1 / -1";
|
||||
cell.textContent = message;
|
||||
row.append(cell);
|
||||
tableBody.replaceChildren(row);
|
||||
|
||||
+199
-25
@@ -2,13 +2,18 @@
|
||||
const fieldGrid = document.querySelector("#fieldGrid");
|
||||
const variantHeader = document.querySelector("#variantHeader");
|
||||
const manufacturerSelect = document.querySelector("#manufacturerSelect");
|
||||
const manufacturerTrigger = document.querySelector("#manufacturerTrigger");
|
||||
const manufacturerMenu = document.querySelector("#manufacturerMenu");
|
||||
const manufacturerSearch = document.querySelector("#manufacturerSearch");
|
||||
const manufacturerOptions = document.querySelector("#manufacturerOptions");
|
||||
const manufacturerValue = document.querySelector("#manufacturerValue");
|
||||
const languageSelect = document.querySelector("#languageSelect");
|
||||
const recordIdElement = document.querySelector("#recordId");
|
||||
const loadProductButton = document.querySelector("#loadProductButton");
|
||||
const previousProductButton = document.querySelector("#previousProductButton");
|
||||
const nextProductButton = document.querySelector("#nextProductButton");
|
||||
const tableBody = document.querySelector(".variant-table tbody");
|
||||
const variantTable = document.querySelector(".variant-table");
|
||||
const tableBody = document.querySelector("#variantRows");
|
||||
const variantTable = document.querySelector(".variant-grid");
|
||||
const pictureSlot = document.querySelector("#pictureSlot");
|
||||
const scanInput = document.querySelector("#scanInput");
|
||||
const suggestedModal = document.querySelector("#suggestedModal");
|
||||
@@ -22,6 +27,10 @@ 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");
|
||||
const sidebarOverlay = document.querySelector("#sidebarOverlay");
|
||||
const sourceModal = document.querySelector("#sourceModal");
|
||||
const sourceTitle = document.querySelector("#sourceTitle");
|
||||
const sourceList = document.querySelector("#sourceList");
|
||||
@@ -39,6 +48,12 @@ const localDbFields = document.querySelector("#localDbFields");
|
||||
const localDbInputs = document.querySelectorAll("#localDbFields input");
|
||||
const testLocalDbButton = document.querySelector("#testLocalDbButton");
|
||||
const localDbTestResult = document.querySelector("#localDbTestResult");
|
||||
const lightContrastSelect = document.querySelector("#lightContrastSelect");
|
||||
const darkContrastSelect = document.querySelector("#darkContrastSelect");
|
||||
const catalogScreen = document.querySelector(".catalog-screen");
|
||||
const secondaryPanelToggle = document.querySelector("#secondaryPanelToggle");
|
||||
const mainWorkspaceToggle = document.querySelector("#mainWorkspaceToggle");
|
||||
let sidebarUserClosed = false;
|
||||
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init = {}) => {
|
||||
@@ -46,7 +61,10 @@ window.fetch = (input, init = {}) => {
|
||||
if (!url.startsWith("/api/")) return nativeFetch(input, init);
|
||||
|
||||
const headers = new Headers(init.headers || (typeof input !== "string" ? input.headers : undefined));
|
||||
headers.set("X-Database-Mode", localStorage.getItem("catalog-maker:database-mode") || "local");
|
||||
const selectedMode = localStorage.getItem("catalog-maker:database-mode") || "local";
|
||||
// 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");
|
||||
return nativeFetch(input, { ...init, headers });
|
||||
};
|
||||
|
||||
@@ -125,6 +143,7 @@ const productFieldMap = {
|
||||
|
||||
await loadStatus();
|
||||
loadTheme();
|
||||
loadAppearance();
|
||||
renderFields();
|
||||
renderTableHeader();
|
||||
setTableWidth();
|
||||
@@ -132,6 +151,8 @@ await loadManufacturers();
|
||||
await loadLanguages();
|
||||
|
||||
manufacturerSelect.addEventListener("change", () => {
|
||||
syncManufacturerPickerLabel();
|
||||
closeManufacturerPicker();
|
||||
if (manufacturerSelect.value) {
|
||||
loadMappingSources();
|
||||
loadProductForSelectedManufacturer(1);
|
||||
@@ -141,12 +162,29 @@ manufacturerSelect.addEventListener("change", () => {
|
||||
}
|
||||
});
|
||||
|
||||
manufacturerTrigger.addEventListener("click", toggleManufacturerPicker);
|
||||
manufacturerSearch.addEventListener("input", renderManufacturerOptions);
|
||||
manufacturerSearch.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeManufacturerPicker();
|
||||
});
|
||||
document.addEventListener("click", (event) => {
|
||||
if (!event.target.closest("#manufacturerPicker")) closeManufacturerPicker();
|
||||
});
|
||||
|
||||
loadProductButton.disabled = true;
|
||||
loadProductButton.title = "Load existing product attribute info.";
|
||||
loadProductButton.addEventListener("click", loadProductInfoForCurrentProduct);
|
||||
findSourcesButton.addEventListener("click", findSourcesForCurrentProduct);
|
||||
getPicturesButton.addEventListener("click", getPicturesForCurrentProduct);
|
||||
parserDrawerToggle.addEventListener("click", toggleParserDrawer);
|
||||
secondaryPanelToggle.addEventListener("click", toggleSecondaryPanel);
|
||||
mainWorkspaceToggle.addEventListener("click", toggleMainWorkspace);
|
||||
sidebarOpenButton.addEventListener("click", openSidebar);
|
||||
sidebarCloseButton.addEventListener("click", closeSidebar);
|
||||
sidebarOverlay.addEventListener("click", closeSidebar);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") closeSidebar();
|
||||
});
|
||||
|
||||
previousProductButton.addEventListener("click", () => {
|
||||
if (!manufacturerSelect.value || currentPosition <= 1) return;
|
||||
@@ -183,8 +221,11 @@ for (const input of permissionInputs) input.addEventListener("change", saveSetti
|
||||
for (const input of transactionModeInputs) input.addEventListener("change", saveSettings);
|
||||
for (const input of localDbInputs) input.addEventListener("change", saveLocalDbSettings);
|
||||
testLocalDbButton.addEventListener("click", testLocalDbConnection);
|
||||
lightContrastSelect.addEventListener("change", saveAppearance);
|
||||
darkContrastSelect.addEventListener("change", saveAppearance);
|
||||
|
||||
loadDatabaseMode();
|
||||
syncSidebarForViewport();
|
||||
|
||||
setTimeout(() => {
|
||||
if (manufacturerSelect.value) {
|
||||
@@ -229,19 +270,24 @@ function renderFields() {
|
||||
function renderTableHeader() {
|
||||
variantHeader.replaceChildren(
|
||||
...variantColumns.map((column) => {
|
||||
const th = document.createElement("th");
|
||||
th.textContent = column.label;
|
||||
th.dataset.column = column.key;
|
||||
th.style.width = `${column.width}px`;
|
||||
th.style.textAlign = column.align || "";
|
||||
return th;
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "variant-header-cell";
|
||||
cell.setAttribute("role", "columnheader");
|
||||
cell.textContent = column.label;
|
||||
cell.dataset.column = column.key;
|
||||
cell.style.textAlign = column.align || "";
|
||||
return cell;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setTableWidth() {
|
||||
const totalWidth = variantColumns.reduce((sum, column) => sum + column.width, 0);
|
||||
variantTable.style.minWidth = `${Math.max(totalWidth, 820)}px`;
|
||||
variantTable.style.setProperty("--variant-min-width", `${Math.max(totalWidth, 820)}px`);
|
||||
variantTable.style.setProperty(
|
||||
"--variant-columns",
|
||||
variantColumns.map((column) => `${column.width}px`).join(" "),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadManufacturers() {
|
||||
@@ -255,8 +301,64 @@ async function loadManufacturers() {
|
||||
manufacturerSelect.append(createOption(String(manufacturer.id), manufacturer.name));
|
||||
}
|
||||
if (data.message) manufacturerSelect.append(createOption("", data.message));
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
} catch (error) {
|
||||
manufacturerSelect.append(createOption("", error.message || "Local DB is not configured."));
|
||||
syncManufacturerPickerLabel();
|
||||
renderManufacturerOptions();
|
||||
}
|
||||
}
|
||||
|
||||
function toggleManufacturerPicker() {
|
||||
if (manufacturerMenu.hidden) {
|
||||
manufacturerMenu.hidden = false;
|
||||
manufacturerTrigger.setAttribute("aria-expanded", "true");
|
||||
manufacturerSearch.value = "";
|
||||
renderManufacturerOptions();
|
||||
if (manufacturerSelect.options.length > 11) manufacturerSearch.focus();
|
||||
} else {
|
||||
closeManufacturerPicker();
|
||||
}
|
||||
}
|
||||
|
||||
function closeManufacturerPicker() {
|
||||
manufacturerMenu.hidden = true;
|
||||
manufacturerTrigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
|
||||
function syncManufacturerPickerLabel() {
|
||||
const option = manufacturerSelect.selectedOptions[0];
|
||||
manufacturerValue.textContent = option?.textContent || "Select manufacturer";
|
||||
}
|
||||
|
||||
function renderManufacturerOptions() {
|
||||
const options = [...manufacturerSelect.options].filter((option) => option.value);
|
||||
const query = manufacturerSearch.value.trim().toLocaleLowerCase();
|
||||
const filtered = options.filter((option) => option.textContent.toLocaleLowerCase().includes(query));
|
||||
const visible = filtered.slice(0, 10);
|
||||
|
||||
manufacturerOptions.replaceChildren(
|
||||
...visible.map((option) => {
|
||||
const item = document.createElement("button");
|
||||
item.className = "manufacturer-option";
|
||||
item.type = "button";
|
||||
item.setAttribute("role", "option");
|
||||
item.textContent = option.textContent;
|
||||
item.setAttribute("aria-selected", String(option.value === manufacturerSelect.value));
|
||||
item.addEventListener("click", () => {
|
||||
manufacturerSelect.value = option.value;
|
||||
manufacturerSelect.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
});
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
|
||||
if (!visible.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "manufacturer-empty";
|
||||
empty.textContent = "No manufacturer found.";
|
||||
manufacturerOptions.append(empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -837,6 +939,47 @@ function toggleParserDrawer() {
|
||||
parserDrawerToggle.querySelector(".drawer-chevron").textContent = isOpen ? "⌄" : "⌃";
|
||||
}
|
||||
|
||||
function syncSidebarForViewport() {
|
||||
if (window.matchMedia("(min-width: 981px)").matches && !sidebarUserClosed) openSidebar();
|
||||
else closeSidebar();
|
||||
}
|
||||
|
||||
function openSidebar() {
|
||||
sidebarUserClosed = false;
|
||||
sidebarDrawer.classList.add("is-open");
|
||||
sidebarOverlay.hidden = false;
|
||||
sidebarOpenButton.hidden = true;
|
||||
sidebarOpenButton.setAttribute("aria-expanded", "true");
|
||||
sidebarDrawer.setAttribute("aria-hidden", "false");
|
||||
catalogScreen.classList.remove("sidebar-collapsed");
|
||||
}
|
||||
|
||||
function closeSidebar() {
|
||||
sidebarUserClosed = true;
|
||||
sidebarDrawer.classList.remove("is-open");
|
||||
sidebarOverlay.hidden = true;
|
||||
sidebarOpenButton.hidden = false;
|
||||
sidebarOpenButton.setAttribute("aria-expanded", "false");
|
||||
sidebarDrawer.setAttribute("aria-hidden", "true");
|
||||
catalogScreen.classList.add("sidebar-collapsed");
|
||||
}
|
||||
|
||||
function toggleSecondaryPanel() {
|
||||
const isCollapsed = catalogScreen.classList.toggle("secondary-collapsed");
|
||||
secondaryPanelToggle.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
secondaryPanelToggle.setAttribute("aria-label", isCollapsed ? "Expand product details" : "Collapse product details");
|
||||
secondaryPanelToggle.title = isCollapsed ? "Expand product details" : "Collapse product details";
|
||||
secondaryPanelToggle.textContent = isCollapsed ? "›" : "‹";
|
||||
}
|
||||
|
||||
function toggleMainWorkspace() {
|
||||
const isCollapsed = catalogScreen.classList.toggle("workspace-collapsed");
|
||||
mainWorkspaceToggle.setAttribute("aria-expanded", String(!isCollapsed));
|
||||
mainWorkspaceToggle.setAttribute("aria-label", isCollapsed ? "Expand main workspace" : "Collapse main workspace");
|
||||
mainWorkspaceToggle.title = isCollapsed ? "Expand main workspace" : "Collapse main workspace";
|
||||
mainWorkspaceToggle.textContent = isCollapsed ? "‹" : "›";
|
||||
}
|
||||
|
||||
function loadTheme() {
|
||||
const isDark = localStorage.getItem("catalog-maker:theme") === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
@@ -844,6 +987,28 @@ function loadTheme() {
|
||||
themeToggle.setAttribute("aria-pressed", String(isDark));
|
||||
}
|
||||
|
||||
function loadAppearance() {
|
||||
const settings = JSON.parse(localStorage.getItem("catalog-maker:appearance") || "{}");
|
||||
lightContrastSelect.value = settings.lightContrast || "medium";
|
||||
darkContrastSelect.value = settings.darkContrast || "medium";
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
function saveAppearance() {
|
||||
localStorage.setItem("catalog-maker:appearance", JSON.stringify({
|
||||
lightContrast: lightContrastSelect.value,
|
||||
darkContrast: darkContrastSelect.value,
|
||||
}));
|
||||
applyAppearance();
|
||||
}
|
||||
|
||||
function applyAppearance() {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("contrast-light-low", "contrast-light-medium", "contrast-light-high", "contrast-dark-low", "contrast-dark-medium", "contrast-dark-high");
|
||||
root.classList.add(`contrast-light-${lightContrastSelect.value || "medium"}`);
|
||||
root.classList.add(`contrast-dark-${darkContrastSelect.value || "medium"}`);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const isDark = !document.documentElement.classList.contains("dark");
|
||||
localStorage.setItem("catalog-maker:theme", isDark ? "dark" : "light");
|
||||
@@ -874,7 +1039,7 @@ function loadDatabaseMode() {
|
||||
}
|
||||
|
||||
function saveDatabaseMode(event) {
|
||||
const mode = event.target.value === "live" ? "live" : "local";
|
||||
const mode = ["local", "live", "scrape"].includes(event.target.value) ? event.target.value : "local";
|
||||
localStorage.setItem("catalog-maker:database-mode", mode);
|
||||
syncDatabasePermissions(mode);
|
||||
syncLocalDbFields(mode);
|
||||
@@ -922,10 +1087,10 @@ function syncLocalDbFields(mode) {
|
||||
}
|
||||
|
||||
function syncDatabasePermissions(mode) {
|
||||
const isLive = mode === "live";
|
||||
const isReadOnly = mode === "live" || mode === "scrape";
|
||||
for (const input of permissionInputs) {
|
||||
input.disabled = isLive;
|
||||
if (isLive) input.checked = false;
|
||||
input.disabled = isReadOnly;
|
||||
if (isReadOnly) input.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -945,7 +1110,7 @@ function refreshDatabaseStatusLabel() {
|
||||
const writesEnabled = mode === "local" && (
|
||||
permissions.allowInsertToggle || permissions.allowUpdateToggle || permissions.allowDeleteToggle
|
||||
);
|
||||
const dbLabel = mode === "live" ? "Live DB" : "Local DB";
|
||||
const dbLabel = mode === "live" ? "Live 9bplus DB" : mode === "scrape" ? "Local scrape DB" : "Local 9bplus DB";
|
||||
const configuredDriver = databaseStatus.dataset.driver;
|
||||
const connectionError = (mode === "local" && configuredDriver !== "mariadb")
|
||||
|| (mode === "live" && configuredDriver !== "endpoint");
|
||||
@@ -960,8 +1125,10 @@ function refreshDatabaseStatusLabel() {
|
||||
|
||||
function renderDatabaseModeNote(mode) {
|
||||
databaseModeNote.textContent = mode === "live"
|
||||
? "Live DB selected for preparation. The current project still stays read-only."
|
||||
: "Local test database selected.";
|
||||
? "Live 9bplus DB selected for read-only data."
|
||||
: mode === "scrape"
|
||||
? "Local scrape DB selected for scraper settings and scraped data. Catalog reads stay on Local 9bplus DB."
|
||||
: "Local 9bplus DB selected.";
|
||||
}
|
||||
|
||||
function renderProduct(product) {
|
||||
@@ -996,20 +1163,24 @@ function renderCombinations(combinations) {
|
||||
|
||||
tableBody.replaceChildren(
|
||||
...combinations.map((item) => {
|
||||
const row = document.createElement("tr");
|
||||
const row = document.createElement("div");
|
||||
row.className = "variant-row";
|
||||
row.setAttribute("role", "row");
|
||||
|
||||
row.append(
|
||||
...variantColumns.map((column) => {
|
||||
const value = item[column.key];
|
||||
const text = formatTableValue(value, column);
|
||||
const cell = document.createElement("td");
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "variant-cell";
|
||||
cell.setAttribute("role", "cell");
|
||||
cell.textContent = text;
|
||||
cell.title = text;
|
||||
cell.dataset.column = column.key;
|
||||
cell.style.width = `${column.width}px`;
|
||||
cell.dataset.label = column.label;
|
||||
cell.style.textAlign = column.align || "";
|
||||
if ((column.problem && item.hasProblem) || (column.key === "eanStatus" && text)) {
|
||||
cell.className = "problem-ean";
|
||||
cell.classList.add("problem-ean");
|
||||
}
|
||||
return cell;
|
||||
}),
|
||||
@@ -1035,10 +1206,13 @@ function clearProductState(message) {
|
||||
}
|
||||
|
||||
function setTableMessage(message) {
|
||||
const row = document.createElement("tr");
|
||||
const cell = document.createElement("td");
|
||||
const row = document.createElement("div");
|
||||
row.className = "variant-row variant-message-row";
|
||||
row.setAttribute("role", "row");
|
||||
const cell = document.createElement("div");
|
||||
cell.className = "empty-row";
|
||||
cell.colSpan = variantColumns.length;
|
||||
cell.setAttribute("role", "cell");
|
||||
cell.style.gridColumn = "1 / -1";
|
||||
cell.textContent = message;
|
||||
row.append(cell);
|
||||
tableBody.replaceChildren(row);
|
||||
@@ -1177,4 +1351,4 @@ async function loadProductInfoForCurrentProduct() {
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export {};
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
<symbol id="erase" viewBox="0 0 24 24"><path d="m4 15 8-8a2 2 0 0 1 2.8 0l4.2 4.2a2 2 0 0 1 0 2.8l-5 5H7zM13 19h7" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.8"/></symbol>
|
||||
<symbol id="moon" viewBox="0 0 24 24"><path d="M20 15.5A8.5 8.5 0 0 1 8.5 4 8.5 8.5 0 1 0 20 15.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.8"/></symbol>
|
||||
<symbol id="settings" viewBox="0 0 24 24"><path d="M12 8.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="m19 13.5 1.2 1-.2 2-1.5.8-.3 1.6-1.8.9-1.4-.7-1.5.7-1.8-.9-.3-1.6-1.5-.8-.2-2 1.2-1-.1-1.5-1.2-1 .2-2 1.5-.8.3-1.6 1.8-.9 1.4.7 1.5-.7 1.8.9.3 1.6 1.5.8.2 2-1.2 1.1.1 1.5Z" fill="none" stroke="currentColor" stroke-linejoin="round" stroke-width="1.4"/></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>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.1 KiB |
+68
-17
@@ -10,7 +10,19 @@
|
||||
</head>
|
||||
<body>
|
||||
<main class="catalog-screen">
|
||||
<aside class="control-panel">
|
||||
<button class="sidebar-open-button" id="sidebarOpenButton" type="button" aria-controls="sidebarDrawer" aria-expanded="false" aria-label="Open sidebar" title="Open sidebar">
|
||||
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#menu"></use></svg>
|
||||
</button>
|
||||
|
||||
<button class="sidebar-overlay" id="sidebarOverlay" type="button" aria-label="Close sidebar"></button>
|
||||
|
||||
<aside class="control-panel sidebar-drawer" id="sidebarDrawer" aria-label="Catalog controls">
|
||||
<div class="sidebar-drawer-header">
|
||||
<span class="sidebar-drawer-title">Catalog controls</span>
|
||||
<button class="sidebar-close-button" id="sidebarCloseButton" type="button" aria-label="Close sidebar" title="Close sidebar">
|
||||
<svg class="sidebar-icon" aria-hidden="true"><use href="/icons.svg#close"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="record-id" id="recordId"> </div>
|
||||
|
||||
<div class="arrow-row">
|
||||
@@ -48,7 +60,17 @@
|
||||
<option value="">Select language</option>
|
||||
</select>
|
||||
|
||||
<select class="input-like" id="manufacturerSelect" aria-label="Manufacturer">
|
||||
<div class="manufacturer-picker" id="manufacturerPicker">
|
||||
<button class="manufacturer-trigger" id="manufacturerTrigger" type="button" aria-haspopup="listbox" aria-expanded="false">
|
||||
<span id="manufacturerValue">Select manufacturer</span>
|
||||
<span class="picker-chevron" aria-hidden="true">⌄</span>
|
||||
</button>
|
||||
<div class="manufacturer-menu" id="manufacturerMenu" hidden>
|
||||
<input class="manufacturer-search" id="manufacturerSearch" type="search" placeholder="Search manufacturer..." aria-label="Search manufacturer" />
|
||||
<div class="manufacturer-options" id="manufacturerOptions" role="listbox"></div>
|
||||
</div>
|
||||
</div>
|
||||
<select class="input-like native-manufacturer-select" id="manufacturerSelect" aria-label="Manufacturer">
|
||||
<option value="">Select manufacturer</option>
|
||||
</select>
|
||||
|
||||
@@ -74,22 +96,26 @@
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section class="product-panel">
|
||||
<section class="product-panel secondary-panel" aria-label="Product details">
|
||||
<div class="column-header">
|
||||
<span>Product details</span>
|
||||
<button class="column-toggle" id="secondaryPanelToggle" type="button" aria-expanded="true" aria-controls="fieldGrid" aria-label="Collapse product details" title="Collapse product details">‹</button>
|
||||
</div>
|
||||
<div class="field-grid" id="fieldGrid"></div>
|
||||
</section>
|
||||
|
||||
<section class="data-panel">
|
||||
<section class="data-panel main-workspace" aria-label="Catalog workspace">
|
||||
<div class="column-header workspace-header">
|
||||
<span>Main workspace</span>
|
||||
<button class="column-toggle" id="mainWorkspaceToggle" type="button" aria-expanded="true" aria-controls="variantGrid" aria-label="Collapse main workspace" title="Collapse main workspace">›</button>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="variant-table">
|
||||
<thead>
|
||||
<tr id="variantHeader"></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="empty-row" id="emptyTableMessage" colspan="13">Select manufacturer to load data.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="variant-grid" id="variantGrid" role="table" aria-label="Product combinations">
|
||||
<div class="variant-header" id="variantHeader" role="row"></div>
|
||||
<div class="variant-rows" id="variantRows" role="rowgroup">
|
||||
<div class="empty-row" id="emptyTableMessage" role="row">Select manufacturer to load data.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
@@ -125,11 +151,15 @@
|
||||
<legend>Database</legend>
|
||||
<label class="settings-option">
|
||||
<input type="radio" name="databaseMode" value="local" checked />
|
||||
<span>Local DB</span>
|
||||
<span>Local 9bplus DB</span>
|
||||
</label>
|
||||
<label class="settings-option">
|
||||
<input type="radio" name="databaseMode" value="live" />
|
||||
<span>Live DB</span>
|
||||
<span>Live 9bplus DB</span>
|
||||
</label>
|
||||
<label class="settings-option">
|
||||
<input type="radio" name="databaseMode" value="scrape" />
|
||||
<span>Local scrape DB</span>
|
||||
</label>
|
||||
<div class="local-db-fields" id="localDbFields">
|
||||
<label class="settings-field">
|
||||
@@ -155,7 +185,28 @@
|
||||
<button class="settings-test-button" id="testLocalDbButton" type="button">Test connection</button>
|
||||
<p class="settings-note" id="localDbTestResult">Connection not tested.</p>
|
||||
</div>
|
||||
<p class="settings-note" id="databaseModeNote">Local test database selected.</p>
|
||||
<p class="settings-note" id="databaseModeNote">Local 9bplus database selected.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
<legend>Appearance</legend>
|
||||
<label class="settings-field">
|
||||
<span>Light contrast</span>
|
||||
<select class="settings-select" id="lightContrastSelect">
|
||||
<option value="low">Soft</option>
|
||||
<option value="medium" selected>Balanced</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="settings-field">
|
||||
<span>Dark contrast</span>
|
||||
<select class="settings-select" id="darkContrastSelect">
|
||||
<option value="low">Soft</option>
|
||||
<option value="medium" selected>Balanced</option>
|
||||
<option value="high">High</option>
|
||||
</select>
|
||||
</label>
|
||||
<p class="settings-note">Controls the contrast of the left sidebar and its controls.</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="settings-group">
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,33 @@
|
||||
import fs from "node:fs/promises";
|
||||
import mariadb from "mariadb";
|
||||
|
||||
async function readEnv() {
|
||||
return Object.fromEntries(
|
||||
(await fs.readFile(".env", "utf8"))
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("#") && line.includes("="))
|
||||
.map((line) => {
|
||||
const index = line.indexOf("=");
|
||||
return [line.slice(0, index), line.slice(index + 1)];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const env = await readEnv();
|
||||
const connection = await mariadb.createConnection({
|
||||
host: env.DB_HOST || "127.0.0.1",
|
||||
port: Number(env.DB_PORT || 3306),
|
||||
user: env.DB_USER || "root",
|
||||
password: env.DB_PASSWORD || "",
|
||||
});
|
||||
|
||||
try {
|
||||
const sql = await fs.readFile("sql/local-scrape-db.sql", "utf8");
|
||||
for (const statement of sql.split(/;\s*(?:\r?\n|$)/).map((item) => item.trim()).filter(Boolean)) {
|
||||
await connection.query(statement);
|
||||
}
|
||||
console.log("Local scrape database and schema are ready.");
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
CREATE DATABASE IF NOT EXISTS `catalog_scrape`
|
||||
CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE `catalog_scrape`;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `scrape_sources` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`source_key` VARCHAR(64) NOT NULL,
|
||||
`source_name` VARCHAR(128) NOT NULL,
|
||||
`source_type` ENUM('supplier', 'manufacturer', 'backup', 'other') NOT NULL DEFAULT 'supplier',
|
||||
`base_url` VARCHAR(1024) NOT NULL,
|
||||
`enabled` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`priority` INT NOT NULL DEFAULT 100,
|
||||
`search_primary_key` VARCHAR(64) NULL,
|
||||
`search_fallback_keys` VARCHAR(255) NULL,
|
||||
`search_url_template` VARCHAR(2048) NULL,
|
||||
`fallback_url_template` VARCHAR(2048) NULL,
|
||||
`search_context_keys` VARCHAR(255) NULL,
|
||||
`scraper_config` JSON NULL,
|
||||
`request_delay_ms` INT UNSIGNED NOT NULL DEFAULT 3000,
|
||||
`last_health_status` SMALLINT UNSIGNED NULL,
|
||||
`last_health_checked_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uq_scrape_sources_key` (`source_key`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `scrape_products` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`manufacturer_id` INT UNSIGNED NULL,
|
||||
`id_product` BIGINT UNSIGNED NULL,
|
||||
`supplier_reference` VARCHAR(255) NULL,
|
||||
`ean13` VARCHAR(32) NULL,
|
||||
`color` VARCHAR(255) NULL,
|
||||
`source_id` BIGINT UNSIGNED NOT NULL,
|
||||
`source_url` VARCHAR(2048) NOT NULL,
|
||||
`match_method` ENUM('ean', 'reference', 'name', 'manual') NOT NULL DEFAULT 'ean',
|
||||
`source_product_key` VARCHAR(255) NULL,
|
||||
`product_name` TEXT NULL,
|
||||
`raw_data` JSON NULL,
|
||||
`parsed_data` JSON NULL,
|
||||
`status` ENUM('found', 'parsed', 'error', 'manual') NOT NULL DEFAULT 'found',
|
||||
`error_message` TEXT NULL,
|
||||
`fetched_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_scrape_products_ean` (`ean13`),
|
||||
KEY `idx_scrape_products_reference` (`supplier_reference`),
|
||||
KEY `idx_scrape_products_source` (`source_id`),
|
||||
CONSTRAINT `fk_scrape_products_source` FOREIGN KEY (`source_id`) REFERENCES `scrape_sources` (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `scrape_assets` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`scrape_product_id` BIGINT UNSIGNED NOT NULL,
|
||||
`asset_type` ENUM('image', 'document', 'other') NOT NULL DEFAULT 'image',
|
||||
`asset_url` VARCHAR(2048) NOT NULL,
|
||||
`local_path` VARCHAR(1024) NULL,
|
||||
`content_hash` CHAR(64) NULL,
|
||||
`position` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`alt_text` VARCHAR(512) NULL,
|
||||
`status` ENUM('discovered', 'downloaded', 'error') NOT NULL DEFAULT 'discovered',
|
||||
`error_message` TEXT NULL,
|
||||
`fetched_at` DATETIME NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_scrape_assets_product` (`scrape_product_id`),
|
||||
CONSTRAINT `fk_scrape_assets_product` FOREIGN KEY (`scrape_product_id`) REFERENCES `scrape_products` (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `scrape_runs` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`source_id` BIGINT UNSIGNED NULL,
|
||||
`manufacturer_id` INT UNSIGNED NULL,
|
||||
`started_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`finished_at` DATETIME NULL,
|
||||
`status` ENUM('running', 'completed', 'failed', 'cancelled') NOT NULL DEFAULT 'running',
|
||||
`request_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`success_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`error_count` INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
`notes` TEXT NULL,
|
||||
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_scrape_runs_source` (`source_id`),
|
||||
CONSTRAINT `fk_scrape_runs_source` FOREIGN KEY (`source_id`) REFERENCES `scrape_sources` (`id`)
|
||||
) ENGINE=InnoDB;
|
||||
@@ -36,6 +36,7 @@ export function loadConfig({ configPath = "config/local.json", dryRun } = {}) {
|
||||
name: env.DB_NAME || config.database?.name || "catalog_maker_test",
|
||||
user: env.DB_USER || config.database?.user || "catalog_maker",
|
||||
password: env.DB_PASSWORD || config.database?.password || "",
|
||||
scrapeName: env.SCRAPE_DB_NAME || config.database?.scrapeName || "catalog_scrape",
|
||||
connectionLimit: Number(env.DB_CONNECTION_LIMIT || config.database?.connectionLimit || 5),
|
||||
},
|
||||
};
|
||||
|
||||
+4
-1
@@ -24,6 +24,7 @@ const config = loadConfig();
|
||||
const mimeTypes = new Map([
|
||||
[".html", "text/html; charset=utf-8"],
|
||||
[".css", "text/css; charset=utf-8"],
|
||||
[".js", "text/javascript; charset=utf-8"],
|
||||
[".ts", "text/javascript; charset=utf-8"],
|
||||
[".json", "application/json; charset=utf-8"],
|
||||
[".svg", "image/svg+xml"],
|
||||
@@ -195,6 +196,7 @@ function getStatus() {
|
||||
database: {
|
||||
driver: config.database.driver,
|
||||
mode: config.database.mode,
|
||||
scrapeName: config.database.scrapeName,
|
||||
allowWrites: config.database.driver === "mariadb" && config.database.mode === "local" && config.database.allowWrites,
|
||||
},
|
||||
workbook: path.basename(config.sourceWorkbook),
|
||||
@@ -212,7 +214,8 @@ function getStatus() {
|
||||
}
|
||||
|
||||
function getRequestConfig(request) {
|
||||
const selectedMode = String(request.headers["x-database-mode"] || config.database.mode).toLowerCase();
|
||||
const requestedMode = String(request.headers["x-database-mode"] || config.database.mode).toLowerCase();
|
||||
const selectedMode = requestedMode === "scrape" ? "local" : requestedMode;
|
||||
|
||||
if (selectedMode === "local" && config.database.driver !== "mariadb") {
|
||||
throw new Error("Local DB is selected, but MariaDB is not configured. Live DB was not used.");
|
||||
|
||||
+126
-21
@@ -41,8 +41,19 @@
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.catalog-screen { @apply grid min-h-screen grid-cols-[172px_292px_minmax(720px,1fr)] bg-catalog-surface; }
|
||||
.control-panel { @apply flex min-h-screen flex-col gap-1.5 overflow-y-auto bg-slate-900 p-1.5 text-[11px] text-slate-200; }
|
||||
.catalog-screen { @apply relative grid min-h-screen bg-catalog-surface pl-72; --secondary-track: minmax(240px,292px); --workspace-track: minmax(0,1fr); grid-template-columns: var(--secondary-track) var(--workspace-track); }
|
||||
.catalog-screen.sidebar-collapsed { @apply pl-11; }
|
||||
.catalog-screen.secondary-collapsed { --secondary-track: 44px; }
|
||||
.catalog-screen.workspace-collapsed { --workspace-track: 44px; }
|
||||
.sidebar-drawer { @apply fixed inset-y-0 left-0 z-40 flex w-72 min-h-screen flex-col gap-1.5 overflow-y-auto bg-slate-900 p-1.5 text-[11px] text-slate-200 shadow-xl transition-transform duration-200 ease-out; }
|
||||
.sidebar-drawer-header { @apply flex items-center justify-between border-b border-slate-700 pb-1; }
|
||||
.sidebar-drawer-title { @apply px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-400; }
|
||||
.sidebar-close-button, .sidebar-open-button { @apply inline-flex h-7 w-7 items-center justify-center rounded border border-slate-700 bg-slate-800 text-slate-200 shadow-sm transition hover:border-slate-500 hover:bg-slate-700; }
|
||||
.sidebar-close-button .sidebar-icon, .sidebar-open-button .sidebar-icon { @apply h-4 w-4; }
|
||||
.sidebar-open-button { @apply fixed left-2 top-2 z-30; }
|
||||
.sidebar-overlay { @apply fixed inset-0 z-30 bg-slate-950/50; }
|
||||
.sidebar-overlay[hidden] { display: none; }
|
||||
.sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
|
||||
.sidebar-section { @apply border-t border-slate-700 pt-1; }
|
||||
.drawer-trigger { @apply flex min-h-8 w-full items-center gap-2 rounded border border-slate-700 bg-slate-800 px-2 text-left text-[11px] font-semibold text-slate-100 transition hover:border-slate-500 hover:bg-slate-700; }
|
||||
.drawer-trigger .drawer-chevron { @apply ml-auto text-sm text-slate-400; }
|
||||
@@ -68,6 +79,20 @@
|
||||
.arrow-button:hover:not(:disabled), .action-button:hover:not(:disabled) { @apply border-slate-500 bg-slate-700 text-white; }
|
||||
.arrow-button:disabled, .action-button:disabled { @apply cursor-not-allowed opacity-50; }
|
||||
.input-like { @apply h-7 px-1 text-[11px]; }
|
||||
.native-manufacturer-select { @apply hidden; }
|
||||
.manufacturer-picker { @apply relative; }
|
||||
.manufacturer-trigger { @apply flex h-7 w-full items-center justify-between rounded border border-slate-700 bg-slate-800 px-2 text-[11px] text-slate-200 shadow-sm; }
|
||||
.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 span:first-child { @apply min-w-0 truncate; }
|
||||
.picker-chevron { @apply ml-2 text-slate-400; }
|
||||
.manufacturer-menu { @apply absolute left-0 right-0 top-full z-50 mt-1 rounded border border-slate-300 bg-white p-1 shadow-xl; }
|
||||
.manufacturer-menu[hidden] { display: none; }
|
||||
.manufacturer-search { @apply mb-1 h-7 w-full rounded border border-slate-300 px-2 text-[11px] text-slate-800 outline-none focus:border-catalog-focus focus:ring-2 focus:ring-catalog-focus/25; }
|
||||
.manufacturer-options { @apply max-h-60 overflow-y-auto; }
|
||||
.manufacturer-option { @apply block min-h-7 w-full truncate rounded px-2 py-1 text-left text-[11px] text-slate-700 hover:bg-slate-100; }
|
||||
.manufacturer-option[aria-selected="true"] { @apply bg-slate-200 font-medium text-slate-900; }
|
||||
.manufacturer-empty { @apply px-2 py-2 text-[11px] text-slate-500; }
|
||||
#scanInput { @apply bg-slate-700 placeholder:text-slate-400; }
|
||||
.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; }
|
||||
@@ -88,22 +113,34 @@
|
||||
.mapping-source-status.is-ok { @apply bg-emerald-100 text-emerald-700; }
|
||||
.mapping-source-status.is-bad { @apply bg-red-100 text-red-700; }
|
||||
.mapping-source-status.is-muted { @apply bg-slate-200 text-slate-600; }
|
||||
.product-panel { @apply bg-slate-300 pt-[22px]; }
|
||||
.secondary-panel { @apply min-w-0 border-r border-slate-400 bg-slate-300; }
|
||||
.column-header { @apply flex h-7 items-center justify-between gap-1 border-b border-slate-400 px-1 text-[10px] font-semibold uppercase tracking-wide text-slate-600; }
|
||||
.column-toggle { @apply inline-flex h-5 w-5 items-center justify-center rounded border border-slate-400 bg-white text-sm leading-none text-slate-700 hover:bg-slate-200; }
|
||||
.secondary-panel .field-grid { @apply overflow-hidden; }
|
||||
.secondary-collapsed .secondary-panel .column-header { @apply h-full flex-col justify-start py-1; writing-mode: vertical-rl; }
|
||||
.secondary-collapsed .secondary-panel .field-grid { display: none; }
|
||||
.workspace-collapsed .main-workspace .workspace-header { @apply h-full flex-col justify-start py-1; writing-mode: vertical-rl; }
|
||||
.workspace-collapsed .main-workspace .workspace-header span,
|
||||
.workspace-collapsed .main-workspace .table-wrap { display: none; }
|
||||
.field-grid { @apply grid grid-cols-[132px_minmax(0,168px)] content-start; }
|
||||
.field-label, .field-value { @apply min-h-[22px] border-b border-slate-300 px-1 py-0.5 text-[11px] leading-tight; }
|
||||
.field-label { @apply bg-slate-200 text-slate-800; }
|
||||
.field-value { @apply bg-teal-50 text-right text-slate-900; }
|
||||
.field-value[data-field="manufacturer_name"] { @apply font-medium; }
|
||||
.main-workspace { @apply min-w-0 overflow-hidden bg-catalog-surface; }
|
||||
.data-panel { @apply min-w-0 overflow-hidden bg-catalog-surface; }
|
||||
.table-wrap { @apply h-full overflow-auto; }
|
||||
.variant-table { @apply w-full border-collapse text-[11px]; }
|
||||
.variant-table th, .variant-table td { @apply h-[22px] whitespace-nowrap border-b border-r border-slate-300 px-1 py-0.5 text-center; }
|
||||
.variant-table th { @apply sticky top-0 z-10 bg-slate-200 font-medium text-slate-800; }
|
||||
.variant-table td { @apply bg-slate-50 text-slate-800; }
|
||||
.variant-table tr:nth-child(even) td { @apply bg-slate-100; }
|
||||
.variant-table tr:hover td { @apply bg-slate-200; }
|
||||
.empty-row { @apply py-4 text-slate-500; }
|
||||
.variant-table .problem-ean { @apply bg-red-600 font-semibold text-white; }
|
||||
.variant-grid { @apply min-w-0 text-[11px]; min-width: var(--variant-min-width, 820px); }
|
||||
.variant-header, .variant-row { @apply grid; grid-template-columns: var(--variant-columns); }
|
||||
.variant-header { @apply sticky top-0 z-10; }
|
||||
.variant-header-cell { @apply h-[22px] whitespace-nowrap border-b border-r border-slate-300 bg-slate-200 px-1 py-0.5 font-medium text-slate-800; }
|
||||
.variant-row { @apply min-h-[22px] border-b border-slate-300; }
|
||||
.variant-row:nth-child(even) { @apply bg-slate-100; }
|
||||
.variant-row:hover { @apply bg-slate-200; }
|
||||
.variant-cell { @apply min-w-0 whitespace-nowrap border-r border-slate-300 px-1 py-0.5 text-center text-slate-800; }
|
||||
.variant-cell[data-label=""] { @apply p-0; }
|
||||
.empty-row { @apply py-4 text-center text-slate-500; }
|
||||
.variant-cell.problem-ean { @apply bg-red-600 font-semibold text-white; }
|
||||
.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; }
|
||||
@@ -124,6 +161,7 @@
|
||||
.local-db-fields[hidden] { display: none; }
|
||||
.settings-field { @apply grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 text-[11px] text-slate-700; }
|
||||
.settings-field input { @apply min-w-0 rounded border border-slate-300 bg-white px-1 py-1 text-[11px]; }
|
||||
.settings-select { @apply min-w-0 rounded border border-slate-300 bg-white px-1 py-1 text-[11px] text-slate-800; }
|
||||
.settings-note, .settings-permissions { @apply m-0 text-[10px] leading-tight text-slate-500; }
|
||||
.settings-permissions { @apply grid gap-0.5; }
|
||||
.connection-ok { @apply text-emerald-700; }
|
||||
@@ -147,30 +185,97 @@
|
||||
.picture-candidate img { @apply h-16 w-[72px] object-contain; }
|
||||
.picture-candidate span { @apply min-w-0 truncate; }
|
||||
@media (max-width: 980px) {
|
||||
.catalog-screen { @apply block; }
|
||||
.control-panel { @apply min-h-0 grid grid-cols-2; }
|
||||
.control-panel > * { @apply min-w-0; }
|
||||
.sidebar-footer { @apply col-span-2; }
|
||||
.product-panel { @apply pt-0; }
|
||||
.catalog-screen { @apply flex flex-col pl-0; }
|
||||
.catalog-screen.sidebar-collapsed { @apply pl-0; }
|
||||
.sidebar-drawer { @apply w-[min(18rem,calc(100vw-1rem))] min-h-0; }
|
||||
.sidebar-drawer:not([aria-hidden="true"]) { @apply max-h-screen; }
|
||||
.sidebar-drawer[aria-hidden="true"] { @apply -translate-x-full; }
|
||||
.sidebar-open-button { @apply left-2 top-2; }
|
||||
.secondary-panel { @apply border-r-0 pt-0; }
|
||||
.secondary-panel { @apply order-1; }
|
||||
.main-workspace { @apply order-2; }
|
||||
.table-wrap { @apply max-h-[70vh]; }
|
||||
.column-header { @apply h-7; writing-mode: horizontal-tb; }
|
||||
.secondary-collapsed .secondary-panel .column-header,
|
||||
.workspace-collapsed .main-workspace .workspace-header { @apply h-7 flex-row py-0; writing-mode: horizontal-tb; }
|
||||
.secondary-collapsed .secondary-panel .column-header span,
|
||||
.workspace-collapsed .main-workspace .workspace-header span { display: inline; }
|
||||
.workspace-collapsed .main-workspace .table-wrap { display: none; }
|
||||
.variant-grid { @apply min-w-0; }
|
||||
.variant-header { @apply hidden; }
|
||||
.variant-row { @apply grid grid-cols-2 gap-x-2 border-b border-slate-300 p-2; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.variant-cell { @apply flex min-h-7 items-center justify-between gap-2 border-b border-slate-200 px-1 py-1 text-right; white-space: normal; }
|
||||
.variant-cell::before { content: attr(data-label); @apply shrink-0 text-left text-[10px] font-medium text-slate-500; }
|
||||
.variant-cell[data-label=""]::before { content: none; }
|
||||
.variant-cell[data-label=""] { @apply col-span-2; }
|
||||
.variant-message-row { @apply block; }
|
||||
html.dark .variant-row { @apply border-slate-700; }
|
||||
html.dark .variant-cell { @apply border-slate-700; }
|
||||
}
|
||||
|
||||
@media (min-width: 981px) {
|
||||
.sidebar-overlay { display: none; }
|
||||
}
|
||||
|
||||
html:not(.dark) .control-panel { @apply bg-slate-100 text-slate-700; }
|
||||
html:not(.dark) .sidebar-drawer-header,
|
||||
html:not(.dark) .sidebar-footer { @apply border-slate-300; }
|
||||
html:not(.dark) .sidebar-drawer-title { @apply text-slate-500; }
|
||||
html:not(.dark) .sidebar-close-button,
|
||||
html:not(.dark) .sidebar-open-button,
|
||||
html:not(.dark) .drawer-trigger,
|
||||
html:not(.dark) .arrow-button,
|
||||
html:not(.dark) .action-button,
|
||||
html:not(.dark) .input-like { @apply border-slate-300 bg-white text-slate-700; }
|
||||
html:not(.dark) .sidebar-close-button:hover,
|
||||
html:not(.dark) .sidebar-open-button:hover,
|
||||
html:not(.dark) .drawer-trigger:hover,
|
||||
html:not(.dark) .arrow-button:hover:not(:disabled),
|
||||
html:not(.dark) .action-button:hover:not(:disabled) { @apply border-slate-400 bg-slate-200 text-slate-900; }
|
||||
html:not(.dark) .arrow-button::before { @apply text-slate-700; }
|
||||
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) .app-status { @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) .column-header { @apply border-slate-300 text-slate-600; }
|
||||
html.contrast-light-high:not(.dark) .control-panel { @apply bg-slate-200 text-slate-900; }
|
||||
html.contrast-light-high:not(.dark) .action-button,
|
||||
html.contrast-light-high:not(.dark) .arrow-button,
|
||||
html.contrast-light-high:not(.dark) .input-like,
|
||||
html.contrast-light-high:not(.dark) .manufacturer-trigger { @apply border-slate-500 text-slate-900; }
|
||||
html.contrast-light-high:not(.dark) .sidebar-icon { @apply text-slate-700; }
|
||||
|
||||
html.dark body { @apply bg-slate-950 text-slate-100; }
|
||||
html.dark .control-panel { @apply bg-slate-950; }
|
||||
html.dark .product-panel { @apply bg-slate-800; }
|
||||
html.dark .secondary-panel { @apply border-slate-700 bg-slate-800; }
|
||||
html.dark .data-panel { @apply bg-slate-950; }
|
||||
html.dark .field-label { @apply border-slate-700 bg-slate-800 text-slate-200; }
|
||||
html.dark .field-value { @apply border-slate-700 bg-slate-900 text-slate-100; }
|
||||
html.dark .variant-table th { @apply border-slate-700 bg-slate-800 text-slate-100; }
|
||||
html.dark .variant-table td { @apply border-slate-700 bg-slate-900 text-slate-200; }
|
||||
html.dark .variant-table tr:nth-child(even) td { @apply bg-slate-800; }
|
||||
html.dark .variant-table tr:hover td { @apply bg-slate-700; }
|
||||
html.dark .variant-header-cell { @apply border-slate-700 bg-slate-800 text-slate-100; }
|
||||
html.dark .variant-row { @apply border-slate-700; }
|
||||
html.dark .variant-row:nth-child(even) { @apply bg-slate-800; }
|
||||
html.dark .variant-row:hover { @apply bg-slate-700; }
|
||||
html.dark .variant-cell { @apply border-slate-700 text-slate-200; }
|
||||
html.dark .arrow-button,
|
||||
html.dark .action-button,
|
||||
html.dark .input-like { @apply border-slate-600 bg-slate-800 text-slate-100; }
|
||||
html.dark .arrow-button:hover:not(:disabled),
|
||||
html.dark .action-button:hover:not(:disabled) { @apply border-slate-400 bg-slate-700; }
|
||||
html.dark #scanInput { @apply bg-slate-700 placeholder:text-slate-400; }
|
||||
html.dark .manufacturer-menu { @apply border-slate-700 bg-slate-900; }
|
||||
html.dark .manufacturer-search { @apply border-slate-600 bg-slate-800 text-slate-100; }
|
||||
html.dark .manufacturer-option { @apply text-slate-200 hover:bg-slate-800; }
|
||||
html.dark .manufacturer-option[aria-selected="true"] { @apply bg-slate-700 text-white; }
|
||||
html.dark .column-header { @apply border-slate-700 text-slate-300; }
|
||||
html.contrast-dark-high.dark .control-panel { @apply bg-slate-950 text-white; }
|
||||
html.contrast-dark-high.dark .action-button,
|
||||
html.contrast-dark-high.dark .arrow-button,
|
||||
html.contrast-dark-high.dark .input-like,
|
||||
html.contrast-dark-high.dark .manufacturer-trigger { @apply border-slate-500 bg-slate-700 text-white; }
|
||||
html.contrast-dark-high.dark .sidebar-icon { @apply text-slate-100; }
|
||||
html.dark .settings-dialog,
|
||||
html.dark .suggested-dialog,
|
||||
html.dark .source-dialog { @apply border-slate-700 bg-slate-900; }
|
||||
|
||||
@@ -23,31 +23,75 @@ test("settings modal opens and shows local database controls", async ({ page })
|
||||
await expect(page.locator("#testLocalDbButton")).toBeVisible();
|
||||
});
|
||||
|
||||
test("manufacturer picker shows ten results and supports search", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#manufacturerTrigger").click();
|
||||
|
||||
await expect(page.locator("#manufacturerOptions .manufacturer-option")).toHaveCount(10);
|
||||
await expect(page.locator("#manufacturerSearch")).toBeVisible();
|
||||
await page.locator("#manufacturerSearch").fill("La Sportiva");
|
||||
await expect(page.locator("#manufacturerOptions .manufacturer-option")).toHaveText(["La Sportiva"]);
|
||||
});
|
||||
|
||||
test("catalog maker remains usable on a narrow viewport", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/");
|
||||
|
||||
await expect(page.locator("#sidebarDrawer")).toHaveAttribute("aria-hidden", "true");
|
||||
await page.locator("#sidebarOpenButton").click();
|
||||
await expect(page.locator("#findSourcesButton")).toBeVisible();
|
||||
await expect(page.locator("#settingsButton")).toBeVisible();
|
||||
await expect(page.locator("#sidebarOverlay")).toBeVisible();
|
||||
await expect(page.locator(".secondary-panel")).toHaveCSS("order", "1");
|
||||
await expect(page.locator(".main-workspace")).toHaveCSS("order", "2");
|
||||
await page.screenshot({ path: "test-results/catalog-maker-mobile.png", fullPage: true });
|
||||
});
|
||||
|
||||
test("parser tools open in a sidebar drawer", async ({ page }) => {
|
||||
test("left controls open in a responsive sidebar drawer", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("#parserDrawer")).toBeVisible();
|
||||
await page.locator("#parserDrawerToggle").click();
|
||||
await expect(page.locator("#parserDrawer")).toBeHidden();
|
||||
await expect(page.locator("#parserDrawerToggle")).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.locator("#sidebarDrawer")).toBeVisible();
|
||||
await page.locator("#sidebarCloseButton").click();
|
||||
await expect(page.locator("#sidebarDrawer")).toHaveAttribute("aria-hidden", "true");
|
||||
await page.locator("#sidebarOpenButton").click();
|
||||
await expect(page.locator("#sidebarDrawer")).toBeVisible();
|
||||
await expect(page.locator("#sidebarOverlay")).toBeHidden();
|
||||
});
|
||||
|
||||
test("workspace columns collapse and expand without leaving a gap", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#sidebarCloseButton").click();
|
||||
await expect(page.locator(".catalog-screen")).toHaveClass(/sidebar-collapsed/);
|
||||
const secondaryPanel = await page.locator(".secondary-panel").boundingBox();
|
||||
expect(secondaryPanel?.x).toBeGreaterThanOrEqual(44);
|
||||
|
||||
await page.locator("#secondaryPanelToggle").click();
|
||||
await expect(page.locator(".catalog-screen")).toHaveClass(/secondary-collapsed/);
|
||||
await page.locator("#mainWorkspaceToggle").click();
|
||||
await expect(page.locator(".catalog-screen")).toHaveClass(/workspace-collapsed/);
|
||||
|
||||
await page.locator("#secondaryPanelToggle").click();
|
||||
await page.locator("#mainWorkspaceToggle").click();
|
||||
await expect(page.locator(".catalog-screen")).not.toHaveClass(/secondary-collapsed/);
|
||||
await expect(page.locator(".catalog-screen")).not.toHaveClass(/workspace-collapsed/);
|
||||
});
|
||||
|
||||
test("theme toggle switches and persists dark mode", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.locator("html")).not.toHaveClass(/dark/);
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).not.toHaveClass(/(?:^| )dark(?: |$)/);
|
||||
const lightPanelBackground = await page.locator("#sidebarDrawer").evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
|
||||
await page.locator("#themeToggle").click();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await expect(page.locator("html")).toHaveClass(/(?:^| )dark(?: |$)/);
|
||||
await expect(page.locator("#themeToggle")).toHaveText("Light theme");
|
||||
const darkPanelBackground = await page.locator("#sidebarDrawer").evaluate((element) => getComputedStyle(element).backgroundColor);
|
||||
expect(darkPanelBackground).not.toBe(lightPanelBackground);
|
||||
|
||||
await page.locator("#settingsButton").click();
|
||||
await page.locator("#darkContrastSelect").selectOption("high");
|
||||
await expect(page.locator("html")).toHaveClass(/contrast-dark-high/);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.locator("html")).toHaveClass(/dark/);
|
||||
await expect(page.locator("html")).toHaveClass(/(?:^| )dark(?: |$)/);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,19 @@ test.describe("database connection", () => {
|
||||
await expect(page.locator("#localDbTestResult")).toContainText("Connected:");
|
||||
});
|
||||
|
||||
test("keeps the scrape database as a separate read-only mode", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.locator("#settingsButton").click();
|
||||
|
||||
await expect(page.locator('input[name="databaseMode"][value="local"] + span')).toHaveText("Local 9bplus DB");
|
||||
await expect(page.locator('input[name="databaseMode"][value="live"] + span')).toHaveText("Live 9bplus DB");
|
||||
await page.locator('input[name="databaseMode"][value="scrape"]').check();
|
||||
await expect(page.locator("#databaseModeNote")).toContainText("Local scrape DB selected");
|
||||
await expect(page.locator("#allowInsertToggle")).toBeDisabled();
|
||||
await expect(page.locator("#allowUpdateToggle")).toBeDisabled();
|
||||
await expect(page.locator("#allowDeleteToggle")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("loads manufacturers from Local DB without Live endpoint fallback", async ({ page }) => {
|
||||
const manufacturersResponse = page.waitForResponse("**/api/manufacturers");
|
||||
await page.goto("/");
|
||||
|
||||
Reference in New Issue
Block a user