diff --git a/cypress/e2e/content/actions.spec.js b/cypress/e2e/content/actions.spec.js index e6bf44494a..49e151c509 100644 --- a/cypress/e2e/content/actions.spec.js +++ b/cypress/e2e/content/actions.spec.js @@ -213,6 +213,13 @@ describe("Actions in content editor", () => { }); cy.get("input[name=title]", { timeout: 5000 }).click().type(timestamp); + + cy.getBySelector("ManualMetaFlow").click(); + + cy.getBySelector("metaDescription") + .find("textarea") + .first() + .type(timestamp); cy.getBySelector("CreateItemSaveButton").click(); cy.contains("Created Item", { timeout: 5000 }).should("exist"); @@ -267,4 +274,64 @@ describe("Actions in content editor", () => { // }).should("exist"); // // cy.contains("The item has been purged from the CDN cache", { timeout: 5000 }).should("exist"); // }); + + it("Creates a new content item using AI-generated data", () => { + cy.waitOn("/v1/content/models*", () => { + cy.waitOn("/v1/content/models/*/fields?showDeleted=true", () => { + cy.visit("/content/6-a1a600-k0b6f0/new"); + }); + }); + + cy.intercept("/ai").as("ai"); + cy.wait(5000); + + // Generate AI content for single line text + cy.get("#12-0c3934-8dz720").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Generate AI content for wysiwyg + cy.get("#12-717920-6z46t7").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AITopicField").type("biking"); + cy.getBySelector("AIAudienceField").type("young adults"); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AIApprove").click(); + + // Select AI-assisted metadata generation flow + cy.getBySelector("ManualMetaFlow").click(); + + // Generate AI content for meta title + cy.getBySelector("metaTitle").find("input").clear(); + cy.getBySelector("metaTitle").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + // Generate AI content for meta description + cy.getBySelector("metaDescription") + .find("textarea[name='metaDescription']") + .clear({ force: true }); + cy.getBySelector("metaDescription").find("[data-cy='AIOpen']").click(); + cy.getBySelector("AIGenerate").click(); + + cy.wait("@ai"); + + cy.getBySelector("AISuggestion1").click(); + cy.getBySelector("AIApprove").click(); + + cy.getBySelector("CreateItemSaveButton").click(); + + cy.contains("Created Item", { timeout: 5000 }).should("exist"); + }); }); diff --git a/cypress/e2e/content/analyticsDashboard.spec.js b/cypress/e2e/content/analyticsDashboard.spec.js index 89d96647d8..2dae38aead 100644 --- a/cypress/e2e/content/analyticsDashboard.spec.js +++ b/cypress/e2e/content/analyticsDashboard.spec.js @@ -1,6 +1,6 @@ describe("Analytics dashboard", () => { before(() => { - cy.waitOn("*getPropertyList*", () => { + cy.waitOn("*properties*", () => { cy.visit("/content"); }); }); diff --git a/cypress/e2e/content/content.spec.js b/cypress/e2e/content/content.spec.js index 0d629acca7..5762d0c1f6 100644 --- a/cypress/e2e/content/content.spec.js +++ b/cypress/e2e/content/content.spec.js @@ -184,7 +184,7 @@ describe("Content Specs", () => { }); it("Currency Field", () => { - cy.get("#12-b35c68-jd1s8s input[type=number]") + cy.get("#12-b35c68-jd1s8s input") .focus() .clear() .type("100.00") diff --git a/cypress/e2e/content/singlePageAnalytics.spec.js b/cypress/e2e/content/singlePageAnalytics.spec.js index 59af69ded1..8303e8c104 100644 --- a/cypress/e2e/content/singlePageAnalytics.spec.js +++ b/cypress/e2e/content/singlePageAnalytics.spec.js @@ -1,6 +1,6 @@ describe("Single Page Analytics", () => { before(() => { - cy.waitOn("*getPropertyList*", () => { + cy.waitOn("*properties*", () => { cy.visit("/content/6-a1a600-k0b6f0/7-a1be38-1b42ht/analytics"); }); }); diff --git a/cypress/e2e/schema/field.spec.js b/cypress/e2e/schema/field.spec.js index 4ec52339ce..92625bf5b0 100644 --- a/cypress/e2e/schema/field.spec.js +++ b/cypress/e2e/schema/field.spec.js @@ -17,12 +17,14 @@ const SELECTORS = { FIELD_SELECT_MEDIA: "FieldItem_images", FIELD_SELECT_BOOLEAN: "FieldItem_yes_no", FIELD_SELECT_ONE_TO_ONE: "FieldItem_one_to_one", + FIELD_SELECT_CURRENCY: "FieldItem_currency", MEDIA_CHECKBOX_LIMIT: "MediaCheckbox_limit", MEDIA_CHECKBOX_LOCK: "MediaCheckbox_group_id", DROPDOWN_ADD_OPTION: "DropdownAddOption", DROPDOWN_DELETE_OPTION: "DeleteOption", AUTOCOMPLETE_MODEL_ZUID: "Autocomplete_relatedModelZUID", AUTOCOMPLETE_FIELED_ZUID: "Autocomplete_relatedFieldZUID", + AUTOCOMPLETE_FIELD_CURRENCY: "Autocomplete_currency", INPUT_LABEL: "FieldFormInput_label", INPUT_NAME: "FieldFormInput_name", INPUT_OPTION_LABEL: "OptionLabel", @@ -357,6 +359,44 @@ describe("Schema: Fields", () => { cy.getBySelector(`Field_${fieldName}`).should("exist"); }); + it("Creates a currency field", () => { + cy.intercept("**/fields?showDeleted=true").as("getFields"); + + const fieldLabel = `Currency ${timestamp}`; + const fieldName = `currency_${timestamp}`; + + // Open the add field modal + cy.getBySelector(SELECTORS.ADD_FIELD_BTN).should("exist").click(); + cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("exist"); + + // Select one-to-one relationship field + cy.getBySelector(SELECTORS.FIELD_SELECT_CURRENCY).should("exist").click(); + + // Select default currency + cy.getBySelector(SELECTORS.AUTOCOMPLETE_FIELD_CURRENCY).type("phil"); + cy.get("[role=listbox] [role=option]").first().click(); + + // Fill up fields + cy.getBySelector(SELECTORS.INPUT_LABEL).should("exist").type(fieldLabel); + + // Navigate to rules tab and add default value + cy.getBySelector(SELECTORS.RULES_TAB_BTN).click(); + // click on the default value checkbox + cy.getBySelector(SELECTORS.DEFAULT_VALUE_CHECKBOX).click(); + // enter a default value + cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).type("1000.50"); + // Verify default currency + cy.getBySelector(SELECTORS.DEFAULT_VALUE_INPUT).contains("PHP"); + // Click done + cy.getBySelector(SELECTORS.SAVE_FIELD_BUTTON).should("exist").click(); + cy.getBySelector(SELECTORS.ADD_FIELD_MODAL).should("not.exist"); + + cy.wait("@getFields"); + + // Check if field exists + cy.getBySelector(`Field_${fieldName}`).should("exist"); + }); + it("Creates a field via add another field button", () => { cy.intercept("**/fields?showDeleted=true").as("getFields"); diff --git a/package-lock.json b/package-lock.json index 73d29b28d9..7e2341f4d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,6 @@ "requires": true, "packages": { "": { - "name": "manager-ui", "version": "1.0.1", "license": "Commons Clause License Condition v1.0", "dependencies": { @@ -31,7 +30,7 @@ "@tinymce/tinymce-react": "^4.3.0", "@welldone-software/why-did-you-render": "^6.1.1", "@zesty-io/core": "1.10.0", - "@zesty-io/material": "^0.15.2", + "@zesty-io/material": "^0.15.5", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", @@ -3912,9 +3911,9 @@ } }, "node_modules/@zesty-io/material": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", - "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz", + "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==", "dependencies": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", @@ -18397,9 +18396,9 @@ } }, "@zesty-io/material": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.2.tgz", - "integrity": "sha512-m5dLNBpZtPtXUlo57In+k2ldo8OtAUPLvjLATV7S4qC6GKVSZHpq4Sqvab3YZ8C2dskcHGTos4aOJicgI3/UKA==", + "version": "0.15.5", + "resolved": "https://registry.npmjs.org/@zesty-io/material/-/material-0.15.5.tgz", + "integrity": "sha512-/xSfR3FjmAW9wKSJthXaxyekkysl6i7naF19PP5XIycxEQWMLN8BT7Cvy/ihi99m7anLl8sNzswax4daHTRHkA==", "requires": { "@emotion/react": "^11.9.0", "@emotion/styled": "^11.8.1", diff --git a/package.json b/package.json index 8ec0d7b68e..a7c32655a8 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "@tinymce/tinymce-react": "^4.3.0", "@welldone-software/why-did-you-render": "^6.1.1", "@zesty-io/core": "1.10.0", - "@zesty-io/material": "^0.15.2", + "@zesty-io/material": "^0.15.5", "chart.js": "^3.8.0", "chartjs-adapter-moment": "^1.0.1", "chartjs-plugin-datalabels": "^2.0.0", diff --git a/public/images/flags/ad.svg b/public/images/flags/ad.svg new file mode 100644 index 0000000000..067ab772f6 --- /dev/null +++ b/public/images/flags/ad.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ae.svg b/public/images/flags/ae.svg new file mode 100644 index 0000000000..651ac8523d --- /dev/null +++ b/public/images/flags/ae.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/af.svg b/public/images/flags/af.svg new file mode 100644 index 0000000000..521ac4cfd8 --- /dev/null +++ b/public/images/flags/af.svg @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ag.svg b/public/images/flags/ag.svg new file mode 100644 index 0000000000..243c3d8f9e --- /dev/null +++ b/public/images/flags/ag.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/ai.svg b/public/images/flags/ai.svg new file mode 100644 index 0000000000..628ad9be93 --- /dev/null +++ b/public/images/flags/ai.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/al.svg b/public/images/flags/al.svg new file mode 100644 index 0000000000..1135b4b80a --- /dev/null +++ b/public/images/flags/al.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/am.svg b/public/images/flags/am.svg new file mode 100644 index 0000000000..99fa4dc597 --- /dev/null +++ b/public/images/flags/am.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ao.svg b/public/images/flags/ao.svg new file mode 100644 index 0000000000..b1863bd0f6 --- /dev/null +++ b/public/images/flags/ao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/aq.svg b/public/images/flags/aq.svg new file mode 100644 index 0000000000..53840cccb0 --- /dev/null +++ b/public/images/flags/aq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ar.svg b/public/images/flags/ar.svg new file mode 100644 index 0000000000..d20cbbdcdc --- /dev/null +++ b/public/images/flags/ar.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/arab.svg b/public/images/flags/arab.svg new file mode 100644 index 0000000000..96d27157e9 --- /dev/null +++ b/public/images/flags/arab.svg @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/as.svg b/public/images/flags/as.svg new file mode 100644 index 0000000000..3543556725 --- /dev/null +++ b/public/images/flags/as.svg @@ -0,0 +1,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/at.svg b/public/images/flags/at.svg new file mode 100644 index 0000000000..9d2775c083 --- /dev/null +++ b/public/images/flags/at.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/au.svg b/public/images/flags/au.svg new file mode 100644 index 0000000000..96e80768bb --- /dev/null +++ b/public/images/flags/au.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/aw.svg b/public/images/flags/aw.svg new file mode 100644 index 0000000000..413b7c45b6 --- /dev/null +++ b/public/images/flags/aw.svg @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ax.svg b/public/images/flags/ax.svg new file mode 100644 index 0000000000..0584d713b5 --- /dev/null +++ b/public/images/flags/ax.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/az.svg b/public/images/flags/az.svg new file mode 100644 index 0000000000..3557522110 --- /dev/null +++ b/public/images/flags/az.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/ba.svg b/public/images/flags/ba.svg new file mode 100644 index 0000000000..93bd9cf937 --- /dev/null +++ b/public/images/flags/ba.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/bb.svg b/public/images/flags/bb.svg new file mode 100644 index 0000000000..cecd5cc334 --- /dev/null +++ b/public/images/flags/bb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/bd.svg b/public/images/flags/bd.svg new file mode 100644 index 0000000000..16b794debd --- /dev/null +++ b/public/images/flags/bd.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/be.svg b/public/images/flags/be.svg new file mode 100644 index 0000000000..ac706a0b5a --- /dev/null +++ b/public/images/flags/be.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/bf.svg b/public/images/flags/bf.svg new file mode 100644 index 0000000000..4713822584 --- /dev/null +++ b/public/images/flags/bf.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/bg.svg b/public/images/flags/bg.svg new file mode 100644 index 0000000000..af2d0d07c3 --- /dev/null +++ b/public/images/flags/bg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/bh.svg b/public/images/flags/bh.svg new file mode 100644 index 0000000000..7a2ea549b6 --- /dev/null +++ b/public/images/flags/bh.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/bi.svg b/public/images/flags/bi.svg new file mode 100644 index 0000000000..a4434a955f --- /dev/null +++ b/public/images/flags/bi.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/bj.svg b/public/images/flags/bj.svg new file mode 100644 index 0000000000..0846724d17 --- /dev/null +++ b/public/images/flags/bj.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/bl.svg b/public/images/flags/bl.svg new file mode 100644 index 0000000000..f84cbbaeb1 --- /dev/null +++ b/public/images/flags/bl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/bm.svg b/public/images/flags/bm.svg new file mode 100644 index 0000000000..bab3e0abe0 --- /dev/null +++ b/public/images/flags/bm.svg @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bn.svg b/public/images/flags/bn.svg new file mode 100644 index 0000000000..4b416ebb73 --- /dev/null +++ b/public/images/flags/bn.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bo.svg b/public/images/flags/bo.svg new file mode 100644 index 0000000000..46dc76735e --- /dev/null +++ b/public/images/flags/bo.svg @@ -0,0 +1,674 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bq.svg b/public/images/flags/bq.svg new file mode 100644 index 0000000000..0e6bc76e62 --- /dev/null +++ b/public/images/flags/bq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/br.svg b/public/images/flags/br.svg new file mode 100644 index 0000000000..22c908e7e3 --- /dev/null +++ b/public/images/flags/br.svg @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bs.svg b/public/images/flags/bs.svg new file mode 100644 index 0000000000..5cc918e5ad --- /dev/null +++ b/public/images/flags/bs.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/bt.svg b/public/images/flags/bt.svg new file mode 100644 index 0000000000..798c79b381 --- /dev/null +++ b/public/images/flags/bt.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bv.svg b/public/images/flags/bv.svg new file mode 100644 index 0000000000..40e16d9482 --- /dev/null +++ b/public/images/flags/bv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/bw.svg b/public/images/flags/bw.svg new file mode 100644 index 0000000000..3435608d6c --- /dev/null +++ b/public/images/flags/bw.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/by.svg b/public/images/flags/by.svg new file mode 100644 index 0000000000..7e90ff255c --- /dev/null +++ b/public/images/flags/by.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/bz.svg b/public/images/flags/bz.svg new file mode 100644 index 0000000000..25386a51a4 --- /dev/null +++ b/public/images/flags/bz.svg @@ -0,0 +1,145 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ca.svg b/public/images/flags/ca.svg new file mode 100644 index 0000000000..89da5b7b55 --- /dev/null +++ b/public/images/flags/ca.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/cc.svg b/public/images/flags/cc.svg new file mode 100644 index 0000000000..ddfd180382 --- /dev/null +++ b/public/images/flags/cc.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/cd.svg b/public/images/flags/cd.svg new file mode 100644 index 0000000000..b9cf528941 --- /dev/null +++ b/public/images/flags/cd.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/cefta.svg b/public/images/flags/cefta.svg new file mode 100644 index 0000000000..f748d08a12 --- /dev/null +++ b/public/images/flags/cefta.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cf.svg b/public/images/flags/cf.svg new file mode 100644 index 0000000000..a6cd3670f2 --- /dev/null +++ b/public/images/flags/cf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cg.svg b/public/images/flags/cg.svg new file mode 100644 index 0000000000..f5a0e42d45 --- /dev/null +++ b/public/images/flags/cg.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/ch.svg b/public/images/flags/ch.svg new file mode 100644 index 0000000000..b42d6709cf --- /dev/null +++ b/public/images/flags/ch.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ci.svg b/public/images/flags/ci.svg new file mode 100644 index 0000000000..e400f0c1cd --- /dev/null +++ b/public/images/flags/ci.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/ck.svg b/public/images/flags/ck.svg new file mode 100644 index 0000000000..18e547b17d --- /dev/null +++ b/public/images/flags/ck.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/cl.svg b/public/images/flags/cl.svg new file mode 100644 index 0000000000..5b3c72fa7c --- /dev/null +++ b/public/images/flags/cl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cm.svg b/public/images/flags/cm.svg new file mode 100644 index 0000000000..70adc8b681 --- /dev/null +++ b/public/images/flags/cm.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cn.svg b/public/images/flags/cn.svg new file mode 100644 index 0000000000..10d3489a0e --- /dev/null +++ b/public/images/flags/cn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/co.svg b/public/images/flags/co.svg new file mode 100644 index 0000000000..ebd0a0fb2d --- /dev/null +++ b/public/images/flags/co.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cp.svg b/public/images/flags/cp.svg new file mode 100644 index 0000000000..b8aa9cfd69 --- /dev/null +++ b/public/images/flags/cp.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cr.svg b/public/images/flags/cr.svg new file mode 100644 index 0000000000..5a409eebb2 --- /dev/null +++ b/public/images/flags/cr.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/cu.svg b/public/images/flags/cu.svg new file mode 100644 index 0000000000..053c9ee3a0 --- /dev/null +++ b/public/images/flags/cu.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cv.svg b/public/images/flags/cv.svg new file mode 100644 index 0000000000..aec8994902 --- /dev/null +++ b/public/images/flags/cv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/cw.svg b/public/images/flags/cw.svg new file mode 100644 index 0000000000..bb0ece22e4 --- /dev/null +++ b/public/images/flags/cw.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/cx.svg b/public/images/flags/cx.svg new file mode 100644 index 0000000000..374ff2dab5 --- /dev/null +++ b/public/images/flags/cx.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/cy.svg b/public/images/flags/cy.svg new file mode 100644 index 0000000000..7e3d883da8 --- /dev/null +++ b/public/images/flags/cy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/cz.svg b/public/images/flags/cz.svg new file mode 100644 index 0000000000..7913de3895 --- /dev/null +++ b/public/images/flags/cz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/de.svg b/public/images/flags/de.svg new file mode 100644 index 0000000000..71aa2d2c30 --- /dev/null +++ b/public/images/flags/de.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/dg.svg b/public/images/flags/dg.svg new file mode 100644 index 0000000000..f163caf947 --- /dev/null +++ b/public/images/flags/dg.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/dj.svg b/public/images/flags/dj.svg new file mode 100644 index 0000000000..9b00a82056 --- /dev/null +++ b/public/images/flags/dj.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/dk.svg b/public/images/flags/dk.svg new file mode 100644 index 0000000000..563277f81d --- /dev/null +++ b/public/images/flags/dk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/dm.svg b/public/images/flags/dm.svg new file mode 100644 index 0000000000..f692094ddb --- /dev/null +++ b/public/images/flags/dm.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/do.svg b/public/images/flags/do.svg new file mode 100644 index 0000000000..b1be393ed1 --- /dev/null +++ b/public/images/flags/do.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/dz.svg b/public/images/flags/dz.svg new file mode 100644 index 0000000000..5ff29a74a0 --- /dev/null +++ b/public/images/flags/dz.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/eac.svg b/public/images/flags/eac.svg new file mode 100644 index 0000000000..aaf8133f35 --- /dev/null +++ b/public/images/flags/eac.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ec.svg b/public/images/flags/ec.svg new file mode 100644 index 0000000000..397bfd9822 --- /dev/null +++ b/public/images/flags/ec.svg @@ -0,0 +1,138 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ee.svg b/public/images/flags/ee.svg new file mode 100644 index 0000000000..8b98c2c429 --- /dev/null +++ b/public/images/flags/ee.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/eg.svg b/public/images/flags/eg.svg new file mode 100644 index 0000000000..00d1fa59ee --- /dev/null +++ b/public/images/flags/eg.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/eh.svg b/public/images/flags/eh.svg new file mode 100644 index 0000000000..6aec72883c --- /dev/null +++ b/public/images/flags/eh.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/er.svg b/public/images/flags/er.svg new file mode 100644 index 0000000000..3f4f3f2921 --- /dev/null +++ b/public/images/flags/er.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/es-ct.svg b/public/images/flags/es-ct.svg new file mode 100644 index 0000000000..4d85911402 --- /dev/null +++ b/public/images/flags/es-ct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/es-ga.svg b/public/images/flags/es-ga.svg new file mode 100644 index 0000000000..31657813ea --- /dev/null +++ b/public/images/flags/es-ga.svg @@ -0,0 +1,187 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/es-pv.svg b/public/images/flags/es-pv.svg new file mode 100644 index 0000000000..21c8759ec0 --- /dev/null +++ b/public/images/flags/es-pv.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/es.svg b/public/images/flags/es.svg new file mode 100644 index 0000000000..acdf927f23 --- /dev/null +++ b/public/images/flags/es.svg @@ -0,0 +1,544 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/et.svg b/public/images/flags/et.svg new file mode 100644 index 0000000000..3f99be4860 --- /dev/null +++ b/public/images/flags/et.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/eu.svg b/public/images/flags/eu.svg new file mode 100644 index 0000000000..b0874c1ed4 --- /dev/null +++ b/public/images/flags/eu.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fi.svg b/public/images/flags/fi.svg new file mode 100644 index 0000000000..470be2d07c --- /dev/null +++ b/public/images/flags/fi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/fj.svg b/public/images/flags/fj.svg new file mode 100644 index 0000000000..23fbe57a8d --- /dev/null +++ b/public/images/flags/fj.svg @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fk.svg b/public/images/flags/fk.svg new file mode 100644 index 0000000000..c65bf96de9 --- /dev/null +++ b/public/images/flags/fk.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/fm.svg b/public/images/flags/fm.svg new file mode 100644 index 0000000000..c1b7c97784 --- /dev/null +++ b/public/images/flags/fm.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/fo.svg b/public/images/flags/fo.svg new file mode 100644 index 0000000000..f802d285ac --- /dev/null +++ b/public/images/flags/fo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/fr.svg b/public/images/flags/fr.svg new file mode 100644 index 0000000000..4110e59e4c --- /dev/null +++ b/public/images/flags/fr.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ga.svg b/public/images/flags/ga.svg new file mode 100644 index 0000000000..76edab429c --- /dev/null +++ b/public/images/flags/ga.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gb-eng.svg b/public/images/flags/gb-eng.svg new file mode 100644 index 0000000000..12e3b67d56 --- /dev/null +++ b/public/images/flags/gb-eng.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gb-nir.svg b/public/images/flags/gb-nir.svg new file mode 100644 index 0000000000..e6be8dbc2d --- /dev/null +++ b/public/images/flags/gb-nir.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gb-sct.svg b/public/images/flags/gb-sct.svg new file mode 100644 index 0000000000..f50cd322ac --- /dev/null +++ b/public/images/flags/gb-sct.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/gb-wls.svg b/public/images/flags/gb-wls.svg new file mode 100644 index 0000000000..6e15fd0158 --- /dev/null +++ b/public/images/flags/gb-wls.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/gb.svg b/public/images/flags/gb.svg new file mode 100644 index 0000000000..799138319d --- /dev/null +++ b/public/images/flags/gb.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gd.svg b/public/images/flags/gd.svg new file mode 100644 index 0000000000..cb51e9618e --- /dev/null +++ b/public/images/flags/gd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ge.svg b/public/images/flags/ge.svg new file mode 100644 index 0000000000..d8126ec8d8 --- /dev/null +++ b/public/images/flags/ge.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/gf.svg b/public/images/flags/gf.svg new file mode 100644 index 0000000000..f8fe94c659 --- /dev/null +++ b/public/images/flags/gf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gg.svg b/public/images/flags/gg.svg new file mode 100644 index 0000000000..f8216c8bc1 --- /dev/null +++ b/public/images/flags/gg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/gh.svg b/public/images/flags/gh.svg new file mode 100644 index 0000000000..5c3e3e69ab --- /dev/null +++ b/public/images/flags/gh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/gi.svg b/public/images/flags/gi.svg new file mode 100644 index 0000000000..e2b590afef --- /dev/null +++ b/public/images/flags/gi.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gl.svg b/public/images/flags/gl.svg new file mode 100644 index 0000000000..eb5a52e9e4 --- /dev/null +++ b/public/images/flags/gl.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/gm.svg b/public/images/flags/gm.svg new file mode 100644 index 0000000000..8fe9d66920 --- /dev/null +++ b/public/images/flags/gm.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/gn.svg b/public/images/flags/gn.svg new file mode 100644 index 0000000000..40d6ad4f03 --- /dev/null +++ b/public/images/flags/gn.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/gp.svg b/public/images/flags/gp.svg new file mode 100644 index 0000000000..ee55c4bcd3 --- /dev/null +++ b/public/images/flags/gp.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/gq.svg b/public/images/flags/gq.svg new file mode 100644 index 0000000000..134e442173 --- /dev/null +++ b/public/images/flags/gq.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gr.svg b/public/images/flags/gr.svg new file mode 100644 index 0000000000..599741eec8 --- /dev/null +++ b/public/images/flags/gr.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gs.svg b/public/images/flags/gs.svg new file mode 100644 index 0000000000..1536e073ec --- /dev/null +++ b/public/images/flags/gs.svg @@ -0,0 +1,133 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gt.svg b/public/images/flags/gt.svg new file mode 100644 index 0000000000..f7cffbdc7a --- /dev/null +++ b/public/images/flags/gt.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/gu.svg b/public/images/flags/gu.svg new file mode 100644 index 0000000000..0d66e1bfa8 --- /dev/null +++ b/public/images/flags/gu.svg @@ -0,0 +1,23 @@ + + + + + + + + + + G + U + A + M + + + + + + + + + + diff --git a/public/images/flags/gw.svg b/public/images/flags/gw.svg new file mode 100644 index 0000000000..d470bac9f7 --- /dev/null +++ b/public/images/flags/gw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/gy.svg b/public/images/flags/gy.svg new file mode 100644 index 0000000000..569fb56275 --- /dev/null +++ b/public/images/flags/gy.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/hk.svg b/public/images/flags/hk.svg new file mode 100644 index 0000000000..4fd55bc14b --- /dev/null +++ b/public/images/flags/hk.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/hm.svg b/public/images/flags/hm.svg new file mode 100644 index 0000000000..815c482085 --- /dev/null +++ b/public/images/flags/hm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/hn.svg b/public/images/flags/hn.svg new file mode 100644 index 0000000000..11fde67db9 --- /dev/null +++ b/public/images/flags/hn.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/hr.svg b/public/images/flags/hr.svg new file mode 100644 index 0000000000..44fed27d54 --- /dev/null +++ b/public/images/flags/hr.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ht.svg b/public/images/flags/ht.svg new file mode 100644 index 0000000000..5d48eb93b2 --- /dev/null +++ b/public/images/flags/ht.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/hu.svg b/public/images/flags/hu.svg new file mode 100644 index 0000000000..baddf7f5ea --- /dev/null +++ b/public/images/flags/hu.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/ic.svg b/public/images/flags/ic.svg new file mode 100644 index 0000000000..81e6ee2e13 --- /dev/null +++ b/public/images/flags/ic.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/id.svg b/public/images/flags/id.svg new file mode 100644 index 0000000000..3b7c8fcfd9 --- /dev/null +++ b/public/images/flags/id.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/ie.svg b/public/images/flags/ie.svg new file mode 100644 index 0000000000..049be14de1 --- /dev/null +++ b/public/images/flags/ie.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/il.svg b/public/images/flags/il.svg new file mode 100644 index 0000000000..f43be7e8ed --- /dev/null +++ b/public/images/flags/il.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/im.svg b/public/images/flags/im.svg new file mode 100644 index 0000000000..f06f3d6fe1 --- /dev/null +++ b/public/images/flags/im.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/in.svg b/public/images/flags/in.svg new file mode 100644 index 0000000000..bc47d74911 --- /dev/null +++ b/public/images/flags/in.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/io.svg b/public/images/flags/io.svg new file mode 100644 index 0000000000..77016679ef --- /dev/null +++ b/public/images/flags/io.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/iq.svg b/public/images/flags/iq.svg new file mode 100644 index 0000000000..259da9adc5 --- /dev/null +++ b/public/images/flags/iq.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/ir.svg b/public/images/flags/ir.svg new file mode 100644 index 0000000000..8c6d516216 --- /dev/null +++ b/public/images/flags/ir.svg @@ -0,0 +1,219 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/is.svg b/public/images/flags/is.svg new file mode 100644 index 0000000000..a6588afaef --- /dev/null +++ b/public/images/flags/is.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/it.svg b/public/images/flags/it.svg new file mode 100644 index 0000000000..20a8bfdcc8 --- /dev/null +++ b/public/images/flags/it.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/je.svg b/public/images/flags/je.svg new file mode 100644 index 0000000000..611180d42a --- /dev/null +++ b/public/images/flags/je.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/jm.svg b/public/images/flags/jm.svg new file mode 100644 index 0000000000..269df03836 --- /dev/null +++ b/public/images/flags/jm.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/jo.svg b/public/images/flags/jo.svg new file mode 100644 index 0000000000..d6f927d44f --- /dev/null +++ b/public/images/flags/jo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/jp.svg b/public/images/flags/jp.svg new file mode 100644 index 0000000000..cc1c181ce9 --- /dev/null +++ b/public/images/flags/jp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/ke.svg b/public/images/flags/ke.svg new file mode 100644 index 0000000000..3a67ca3ccd --- /dev/null +++ b/public/images/flags/ke.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kg.svg b/public/images/flags/kg.svg new file mode 100644 index 0000000000..68c210b1cf --- /dev/null +++ b/public/images/flags/kg.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/kh.svg b/public/images/flags/kh.svg new file mode 100644 index 0000000000..c658838f4e --- /dev/null +++ b/public/images/flags/kh.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ki.svg b/public/images/flags/ki.svg new file mode 100644 index 0000000000..0c80328071 --- /dev/null +++ b/public/images/flags/ki.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/km.svg b/public/images/flags/km.svg new file mode 100644 index 0000000000..414d65e47f --- /dev/null +++ b/public/images/flags/km.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kn.svg b/public/images/flags/kn.svg new file mode 100644 index 0000000000..47fe64d617 --- /dev/null +++ b/public/images/flags/kn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/kp.svg b/public/images/flags/kp.svg new file mode 100644 index 0000000000..4d1dbab246 --- /dev/null +++ b/public/images/flags/kp.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/kr.svg b/public/images/flags/kr.svg new file mode 100644 index 0000000000..6947eab2b3 --- /dev/null +++ b/public/images/flags/kr.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kw.svg b/public/images/flags/kw.svg new file mode 100644 index 0000000000..3dd89e9962 --- /dev/null +++ b/public/images/flags/kw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ky.svg b/public/images/flags/ky.svg new file mode 100644 index 0000000000..74a2fea2a1 --- /dev/null +++ b/public/images/flags/ky.svg @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/kz.svg b/public/images/flags/kz.svg new file mode 100644 index 0000000000..04a47f53e8 --- /dev/null +++ b/public/images/flags/kz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/la.svg b/public/images/flags/la.svg new file mode 100644 index 0000000000..6aea6b72b4 --- /dev/null +++ b/public/images/flags/la.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/lb.svg b/public/images/flags/lb.svg new file mode 100644 index 0000000000..8619f2410e --- /dev/null +++ b/public/images/flags/lb.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/lc.svg b/public/images/flags/lc.svg new file mode 100644 index 0000000000..bb256541c6 --- /dev/null +++ b/public/images/flags/lc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/li.svg b/public/images/flags/li.svg new file mode 100644 index 0000000000..68ea26fa30 --- /dev/null +++ b/public/images/flags/li.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/lk.svg b/public/images/flags/lk.svg new file mode 100644 index 0000000000..2c5cdbe09d --- /dev/null +++ b/public/images/flags/lk.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/lr.svg b/public/images/flags/lr.svg new file mode 100644 index 0000000000..e482ab9d74 --- /dev/null +++ b/public/images/flags/lr.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/ls.svg b/public/images/flags/ls.svg new file mode 100644 index 0000000000..a7c01a98ff --- /dev/null +++ b/public/images/flags/ls.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/lt.svg b/public/images/flags/lt.svg new file mode 100644 index 0000000000..90ec5d240e --- /dev/null +++ b/public/images/flags/lt.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/lu.svg b/public/images/flags/lu.svg new file mode 100644 index 0000000000..cc12206812 --- /dev/null +++ b/public/images/flags/lu.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/lv.svg b/public/images/flags/lv.svg new file mode 100644 index 0000000000..6a9e75ec97 --- /dev/null +++ b/public/images/flags/lv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ly.svg b/public/images/flags/ly.svg new file mode 100644 index 0000000000..1eaa51e468 --- /dev/null +++ b/public/images/flags/ly.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ma.svg b/public/images/flags/ma.svg new file mode 100644 index 0000000000..7ce56eff70 --- /dev/null +++ b/public/images/flags/ma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/mc.svg b/public/images/flags/mc.svg new file mode 100644 index 0000000000..9cb6c9e8a0 --- /dev/null +++ b/public/images/flags/mc.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/md.svg b/public/images/flags/md.svg new file mode 100644 index 0000000000..6dc441e177 --- /dev/null +++ b/public/images/flags/md.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/me.svg b/public/images/flags/me.svg new file mode 100644 index 0000000000..d891890746 --- /dev/null +++ b/public/images/flags/me.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mf.svg b/public/images/flags/mf.svg new file mode 100644 index 0000000000..6305edc1c2 --- /dev/null +++ b/public/images/flags/mf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/mg.svg b/public/images/flags/mg.svg new file mode 100644 index 0000000000..5fa2d2440d --- /dev/null +++ b/public/images/flags/mg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mh.svg b/public/images/flags/mh.svg new file mode 100644 index 0000000000..7b9f490755 --- /dev/null +++ b/public/images/flags/mh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mk.svg b/public/images/flags/mk.svg new file mode 100644 index 0000000000..4f5cae77ed --- /dev/null +++ b/public/images/flags/mk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ml.svg b/public/images/flags/ml.svg new file mode 100644 index 0000000000..6f6b71695c --- /dev/null +++ b/public/images/flags/ml.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/mm.svg b/public/images/flags/mm.svg new file mode 100644 index 0000000000..42b4dee2b8 --- /dev/null +++ b/public/images/flags/mm.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/mn.svg b/public/images/flags/mn.svg new file mode 100644 index 0000000000..152c2fcb0f --- /dev/null +++ b/public/images/flags/mn.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/mo.svg b/public/images/flags/mo.svg new file mode 100644 index 0000000000..d39985d05f --- /dev/null +++ b/public/images/flags/mo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/mp.svg b/public/images/flags/mp.svg new file mode 100644 index 0000000000..ff59ebf87b --- /dev/null +++ b/public/images/flags/mp.svg @@ -0,0 +1,86 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mq.svg b/public/images/flags/mq.svg new file mode 100644 index 0000000000..b221951e36 --- /dev/null +++ b/public/images/flags/mq.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/mr.svg b/public/images/flags/mr.svg new file mode 100644 index 0000000000..7558234cbf --- /dev/null +++ b/public/images/flags/mr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ms.svg b/public/images/flags/ms.svg new file mode 100644 index 0000000000..faf07b07fd --- /dev/null +++ b/public/images/flags/ms.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mt.svg b/public/images/flags/mt.svg new file mode 100644 index 0000000000..c597266c36 --- /dev/null +++ b/public/images/flags/mt.svg @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mu.svg b/public/images/flags/mu.svg new file mode 100644 index 0000000000..82d7a3bec5 --- /dev/null +++ b/public/images/flags/mu.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/mv.svg b/public/images/flags/mv.svg new file mode 100644 index 0000000000..10450f9845 --- /dev/null +++ b/public/images/flags/mv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/mw.svg b/public/images/flags/mw.svg new file mode 100644 index 0000000000..d83ddb2178 --- /dev/null +++ b/public/images/flags/mw.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/mx.svg b/public/images/flags/mx.svg new file mode 100644 index 0000000000..f98a89e173 --- /dev/null +++ b/public/images/flags/mx.svg @@ -0,0 +1,382 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/my.svg b/public/images/flags/my.svg new file mode 100644 index 0000000000..89576f69ea --- /dev/null +++ b/public/images/flags/my.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/mz.svg b/public/images/flags/mz.svg new file mode 100644 index 0000000000..2ee6ec14b4 --- /dev/null +++ b/public/images/flags/mz.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/na.svg b/public/images/flags/na.svg new file mode 100644 index 0000000000..35b9f783e1 --- /dev/null +++ b/public/images/flags/na.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/nc.svg b/public/images/flags/nc.svg new file mode 100644 index 0000000000..068f0c69aa --- /dev/null +++ b/public/images/flags/nc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ne.svg b/public/images/flags/ne.svg new file mode 100644 index 0000000000..39a82b8277 --- /dev/null +++ b/public/images/flags/ne.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/nf.svg b/public/images/flags/nf.svg new file mode 100644 index 0000000000..c8b30938d7 --- /dev/null +++ b/public/images/flags/nf.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ng.svg b/public/images/flags/ng.svg new file mode 100644 index 0000000000..81eb35f78e --- /dev/null +++ b/public/images/flags/ng.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ni.svg b/public/images/flags/ni.svg new file mode 100644 index 0000000000..6dcdc9a806 --- /dev/null +++ b/public/images/flags/ni.svg @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/nl.svg b/public/images/flags/nl.svg new file mode 100644 index 0000000000..e90f5b0351 --- /dev/null +++ b/public/images/flags/nl.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/no.svg b/public/images/flags/no.svg new file mode 100644 index 0000000000..a5f2a152a9 --- /dev/null +++ b/public/images/flags/no.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/np.svg b/public/images/flags/np.svg new file mode 100644 index 0000000000..8d71d106bb --- /dev/null +++ b/public/images/flags/np.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/nr.svg b/public/images/flags/nr.svg new file mode 100644 index 0000000000..ff394c4112 --- /dev/null +++ b/public/images/flags/nr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/flags/nu.svg b/public/images/flags/nu.svg new file mode 100644 index 0000000000..4067bafff0 --- /dev/null +++ b/public/images/flags/nu.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/nz.svg b/public/images/flags/nz.svg new file mode 100644 index 0000000000..935d8a749d --- /dev/null +++ b/public/images/flags/nz.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/om.svg b/public/images/flags/om.svg new file mode 100644 index 0000000000..c003f86e46 --- /dev/null +++ b/public/images/flags/om.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pa.svg b/public/images/flags/pa.svg new file mode 100644 index 0000000000..8dc03bc61b --- /dev/null +++ b/public/images/flags/pa.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/pc.svg b/public/images/flags/pc.svg new file mode 100644 index 0000000000..882197da67 --- /dev/null +++ b/public/images/flags/pc.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pe.svg b/public/images/flags/pe.svg new file mode 100644 index 0000000000..33e6cfd417 --- /dev/null +++ b/public/images/flags/pe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/pf.svg b/public/images/flags/pf.svg new file mode 100644 index 0000000000..e06b236e82 --- /dev/null +++ b/public/images/flags/pf.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pg.svg b/public/images/flags/pg.svg new file mode 100644 index 0000000000..237cb6eeed --- /dev/null +++ b/public/images/flags/pg.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/ph.svg b/public/images/flags/ph.svg new file mode 100644 index 0000000000..65489e1cb2 --- /dev/null +++ b/public/images/flags/ph.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/pk.svg b/public/images/flags/pk.svg new file mode 100644 index 0000000000..491e58ab16 --- /dev/null +++ b/public/images/flags/pk.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/pl.svg b/public/images/flags/pl.svg new file mode 100644 index 0000000000..0fa5145241 --- /dev/null +++ b/public/images/flags/pl.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/pm.svg b/public/images/flags/pm.svg new file mode 100644 index 0000000000..19a9330a31 --- /dev/null +++ b/public/images/flags/pm.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/pn.svg b/public/images/flags/pn.svg new file mode 100644 index 0000000000..07958aca12 --- /dev/null +++ b/public/images/flags/pn.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pr.svg b/public/images/flags/pr.svg new file mode 100644 index 0000000000..ec51831dcd --- /dev/null +++ b/public/images/flags/pr.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ps.svg b/public/images/flags/ps.svg new file mode 100644 index 0000000000..b33824a5dd --- /dev/null +++ b/public/images/flags/ps.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/pt.svg b/public/images/flags/pt.svg new file mode 100644 index 0000000000..445cf7f536 --- /dev/null +++ b/public/images/flags/pt.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/pw.svg b/public/images/flags/pw.svg new file mode 100644 index 0000000000..9f89c5f148 --- /dev/null +++ b/public/images/flags/pw.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/py.svg b/public/images/flags/py.svg new file mode 100644 index 0000000000..38e2051eb2 --- /dev/null +++ b/public/images/flags/py.svg @@ -0,0 +1,157 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/qa.svg b/public/images/flags/qa.svg new file mode 100644 index 0000000000..901f3fa761 --- /dev/null +++ b/public/images/flags/qa.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/re.svg b/public/images/flags/re.svg new file mode 100644 index 0000000000..64e788e011 --- /dev/null +++ b/public/images/flags/re.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ro.svg b/public/images/flags/ro.svg new file mode 100644 index 0000000000..fda0f7bec9 --- /dev/null +++ b/public/images/flags/ro.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/rs.svg b/public/images/flags/rs.svg new file mode 100644 index 0000000000..2f971025b8 --- /dev/null +++ b/public/images/flags/rs.svg @@ -0,0 +1,292 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/ru.svg b/public/images/flags/ru.svg new file mode 100644 index 0000000000..cf243011ae --- /dev/null +++ b/public/images/flags/ru.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/rw.svg b/public/images/flags/rw.svg new file mode 100644 index 0000000000..06e26ae44e --- /dev/null +++ b/public/images/flags/rw.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sa.svg b/public/images/flags/sa.svg new file mode 100644 index 0000000000..c0a148663b --- /dev/null +++ b/public/images/flags/sa.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sb.svg b/public/images/flags/sb.svg new file mode 100644 index 0000000000..6066f94cd1 --- /dev/null +++ b/public/images/flags/sb.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sc.svg b/public/images/flags/sc.svg new file mode 100644 index 0000000000..9a46b369b3 --- /dev/null +++ b/public/images/flags/sc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sd.svg b/public/images/flags/sd.svg new file mode 100644 index 0000000000..12818b4110 --- /dev/null +++ b/public/images/flags/sd.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/se.svg b/public/images/flags/se.svg new file mode 100644 index 0000000000..8ba745acaf --- /dev/null +++ b/public/images/flags/se.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/sg.svg b/public/images/flags/sg.svg new file mode 100644 index 0000000000..c4dd4ac9eb --- /dev/null +++ b/public/images/flags/sg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/sh-ac.svg b/public/images/flags/sh-ac.svg new file mode 100644 index 0000000000..22b365832e --- /dev/null +++ b/public/images/flags/sh-ac.svg @@ -0,0 +1,689 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh-hl.svg b/public/images/flags/sh-hl.svg new file mode 100644 index 0000000000..b92e703f27 --- /dev/null +++ b/public/images/flags/sh-hl.svg @@ -0,0 +1,164 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh-ta.svg b/public/images/flags/sh-ta.svg new file mode 100644 index 0000000000..a103aac05f --- /dev/null +++ b/public/images/flags/sh-ta.svg @@ -0,0 +1,76 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sh.svg b/public/images/flags/sh.svg new file mode 100644 index 0000000000..7aba0aec8a --- /dev/null +++ b/public/images/flags/sh.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/si.svg b/public/images/flags/si.svg new file mode 100644 index 0000000000..66a390dcd2 --- /dev/null +++ b/public/images/flags/si.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sj.svg b/public/images/flags/sj.svg new file mode 100644 index 0000000000..bb2799ce73 --- /dev/null +++ b/public/images/flags/sj.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sk.svg b/public/images/flags/sk.svg new file mode 100644 index 0000000000..81476940eb --- /dev/null +++ b/public/images/flags/sk.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/sl.svg b/public/images/flags/sl.svg new file mode 100644 index 0000000000..a07baf75b4 --- /dev/null +++ b/public/images/flags/sl.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/sm.svg b/public/images/flags/sm.svg new file mode 100644 index 0000000000..00e9286c44 --- /dev/null +++ b/public/images/flags/sm.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sn.svg b/public/images/flags/sn.svg new file mode 100644 index 0000000000..7c0673d6d6 --- /dev/null +++ b/public/images/flags/sn.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/so.svg b/public/images/flags/so.svg new file mode 100644 index 0000000000..a581ac63cf --- /dev/null +++ b/public/images/flags/so.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/sr.svg b/public/images/flags/sr.svg new file mode 100644 index 0000000000..5e71c40026 --- /dev/null +++ b/public/images/flags/sr.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ss.svg b/public/images/flags/ss.svg new file mode 100644 index 0000000000..b257aa0b3e --- /dev/null +++ b/public/images/flags/ss.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/st.svg b/public/images/flags/st.svg new file mode 100644 index 0000000000..1294bcb70e --- /dev/null +++ b/public/images/flags/st.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sv.svg b/public/images/flags/sv.svg new file mode 100644 index 0000000000..c811e912f0 --- /dev/null +++ b/public/images/flags/sv.svg @@ -0,0 +1,594 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sx.svg b/public/images/flags/sx.svg new file mode 100644 index 0000000000..18f7a1397b --- /dev/null +++ b/public/images/flags/sx.svg @@ -0,0 +1,56 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/sy.svg b/public/images/flags/sy.svg new file mode 100644 index 0000000000..5225550525 --- /dev/null +++ b/public/images/flags/sy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/sz.svg b/public/images/flags/sz.svg new file mode 100644 index 0000000000..294a2cc1a8 --- /dev/null +++ b/public/images/flags/sz.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tc.svg b/public/images/flags/tc.svg new file mode 100644 index 0000000000..63f13c359b --- /dev/null +++ b/public/images/flags/tc.svg @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/td.svg b/public/images/flags/td.svg new file mode 100644 index 0000000000..fa3bd927c1 --- /dev/null +++ b/public/images/flags/td.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/tf.svg b/public/images/flags/tf.svg new file mode 100644 index 0000000000..fba233563f --- /dev/null +++ b/public/images/flags/tf.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/images/flags/tg.svg b/public/images/flags/tg.svg new file mode 100644 index 0000000000..c63a6d1a94 --- /dev/null +++ b/public/images/flags/tg.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/public/images/flags/th.svg b/public/images/flags/th.svg new file mode 100644 index 0000000000..1e93a61e95 --- /dev/null +++ b/public/images/flags/th.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/tj.svg b/public/images/flags/tj.svg new file mode 100644 index 0000000000..9fba246cde --- /dev/null +++ b/public/images/flags/tj.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tk.svg b/public/images/flags/tk.svg new file mode 100644 index 0000000000..05d3e86ce6 --- /dev/null +++ b/public/images/flags/tk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/tl.svg b/public/images/flags/tl.svg new file mode 100644 index 0000000000..3d0701a2c8 --- /dev/null +++ b/public/images/flags/tl.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/tm.svg b/public/images/flags/tm.svg new file mode 100644 index 0000000000..8b656cc2b8 --- /dev/null +++ b/public/images/flags/tm.svg @@ -0,0 +1,204 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tn.svg b/public/images/flags/tn.svg new file mode 100644 index 0000000000..5735c1984d --- /dev/null +++ b/public/images/flags/tn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/to.svg b/public/images/flags/to.svg new file mode 100644 index 0000000000..d072337066 --- /dev/null +++ b/public/images/flags/to.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/public/images/flags/tr.svg b/public/images/flags/tr.svg new file mode 100644 index 0000000000..b96da21f0e --- /dev/null +++ b/public/images/flags/tr.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/tt.svg b/public/images/flags/tt.svg new file mode 100644 index 0000000000..bc24938cf8 --- /dev/null +++ b/public/images/flags/tt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/tv.svg b/public/images/flags/tv.svg new file mode 100644 index 0000000000..675210ec55 --- /dev/null +++ b/public/images/flags/tv.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/tw.svg b/public/images/flags/tw.svg new file mode 100644 index 0000000000..57fd98b433 --- /dev/null +++ b/public/images/flags/tw.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/tz.svg b/public/images/flags/tz.svg new file mode 100644 index 0000000000..a2cfbca42a --- /dev/null +++ b/public/images/flags/tz.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/images/flags/ua.svg b/public/images/flags/ua.svg new file mode 100644 index 0000000000..a339eb1b9c --- /dev/null +++ b/public/images/flags/ua.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/flags/ug.svg b/public/images/flags/ug.svg new file mode 100644 index 0000000000..737eb2ce1a --- /dev/null +++ b/public/images/flags/ug.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/um.svg b/public/images/flags/um.svg new file mode 100644 index 0000000000..9e9eddaa4a --- /dev/null +++ b/public/images/flags/um.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/un.svg b/public/images/flags/un.svg new file mode 100644 index 0000000000..e57793bc79 --- /dev/null +++ b/public/images/flags/un.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/public/images/flags/us.svg b/public/images/flags/us.svg new file mode 100644 index 0000000000..9cfd0c927f --- /dev/null +++ b/public/images/flags/us.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/public/images/flags/uy.svg b/public/images/flags/uy.svg new file mode 100644 index 0000000000..62c36f8e5e --- /dev/null +++ b/public/images/flags/uy.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/uz.svg b/public/images/flags/uz.svg new file mode 100644 index 0000000000..0ccca1b1b4 --- /dev/null +++ b/public/images/flags/uz.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/va.svg b/public/images/flags/va.svg new file mode 100644 index 0000000000..87e0fbbdcc --- /dev/null +++ b/public/images/flags/va.svg @@ -0,0 +1,190 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vc.svg b/public/images/flags/vc.svg new file mode 100644 index 0000000000..f26c2d8da9 --- /dev/null +++ b/public/images/flags/vc.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/flags/ve.svg b/public/images/flags/ve.svg new file mode 100644 index 0000000000..314e7f5f7f --- /dev/null +++ b/public/images/flags/ve.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vg.svg b/public/images/flags/vg.svg new file mode 100644 index 0000000000..0ee90fb28c --- /dev/null +++ b/public/images/flags/vg.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vi.svg b/public/images/flags/vi.svg new file mode 100644 index 0000000000..4270257799 --- /dev/null +++ b/public/images/flags/vi.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/vn.svg b/public/images/flags/vn.svg new file mode 100644 index 0000000000..7e4bac8f4a --- /dev/null +++ b/public/images/flags/vn.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/public/images/flags/vu.svg b/public/images/flags/vu.svg new file mode 100644 index 0000000000..91e1236a0a --- /dev/null +++ b/public/images/flags/vu.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/wf.svg b/public/images/flags/wf.svg new file mode 100644 index 0000000000..054c57df99 --- /dev/null +++ b/public/images/flags/wf.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/ws.svg b/public/images/flags/ws.svg new file mode 100644 index 0000000000..0e758a7a95 --- /dev/null +++ b/public/images/flags/ws.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/xk.svg b/public/images/flags/xk.svg new file mode 100644 index 0000000000..551e7a4145 --- /dev/null +++ b/public/images/flags/xk.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/xx.svg b/public/images/flags/xx.svg new file mode 100644 index 0000000000..9333be3635 --- /dev/null +++ b/public/images/flags/xx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/flags/ye.svg b/public/images/flags/ye.svg new file mode 100644 index 0000000000..1c9e6d6392 --- /dev/null +++ b/public/images/flags/ye.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/flags/yt.svg b/public/images/flags/yt.svg new file mode 100644 index 0000000000..e7776b3078 --- /dev/null +++ b/public/images/flags/yt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/images/flags/za.svg b/public/images/flags/za.svg new file mode 100644 index 0000000000..d563adb90c --- /dev/null +++ b/public/images/flags/za.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/zm.svg b/public/images/flags/zm.svg new file mode 100644 index 0000000000..13239f5e23 --- /dev/null +++ b/public/images/flags/zm.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/flags/zw.svg b/public/images/flags/zw.svg new file mode 100644 index 0000000000..6399ab4ab3 --- /dev/null +++ b/public/images/flags/zw.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/openai-badge.svg b/public/images/openai-badge.svg new file mode 100644 index 0000000000..cc72d9c62e --- /dev/null +++ b/public/images/openai-badge.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js index d2f6d33212..ec1caf87bb 100644 --- a/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js +++ b/src/apps/code-editor/src/app/components/FileActions/components/EditorActions/EditorActions.js @@ -4,7 +4,9 @@ import { Save } from "./Save"; import { Publish } from "./Publish"; import styles from "./EditorActions.less"; +import { usePermission } from "../../../../../../../../shell/hooks/use-permissions"; export const EditorActions = memo(function EditorActions(props) { + const canPublish = usePermission("PUBLISH"); return (
- + {canPublish && ( + + )}
); }); diff --git a/src/apps/code-editor/src/app/components/FileList/FileList.js b/src/apps/code-editor/src/app/components/FileList/FileList.js index f7c85c180c..823999426a 100644 --- a/src/apps/code-editor/src/app/components/FileList/FileList.js +++ b/src/apps/code-editor/src/app/components/FileList/FileList.js @@ -17,9 +17,10 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCloudUploadAlt } from "@fortawesome/free-solid-svg-icons"; import { resolvePathPart, publishFile } from "../../../store/files"; import { collapseNavItem } from "../../../store/navCode"; - +import { usePermission } from "../../../../../../shell/hooks/use-permissions"; import styles from "./FileList.less"; export const FileList = memo(function FileList(props) { + const canPublish = usePermission("PUBLISH"); // const [branch, setBranch] = useState(props.branch); const [shownFiles, setShownFiles] = useState( props.navCode.tree.sort(byLabel) @@ -47,14 +48,20 @@ export const FileList = memo(function FileList(props) { }; const actions = [ - !file.isLive} - onClick={(file) => props.dispatch(publishFile(file.ZUID, file.status))} - />, + ...(canPublish + ? [ + !file.isLive} + onClick={(file) => + props.dispatch(publishFile(file.ZUID, file.status)) + } + />, + ] + : []), ]; return ( diff --git a/src/apps/content-editor/src/app/ContentEditor.js b/src/apps/content-editor/src/app/ContentEditor.js index 24a5e88ca3..3d22f11e63 100644 --- a/src/apps/content-editor/src/app/ContentEditor.js +++ b/src/apps/content-editor/src/app/ContentEditor.js @@ -31,6 +31,7 @@ import Analytics from "./views/Analytics"; import { ResizableContainer } from "../../../../shell/components/ResizeableContainer"; import { StagedChangesProvider } from "./views/ItemList/StagedChangesContext"; import { SelectedItemsProvider } from "./views/ItemList/SelectedItemsContext"; +import { TableSortProvider } from "./views/ItemList/TableSortProvider"; // Makes sure that other apps using legacy theme does not get affected with the palette let customTheme = createTheme(legacyTheme, { @@ -174,7 +175,9 @@ export default function ContentEditor() { render={() => ( - + + + )} diff --git a/src/apps/content-editor/src/app/components/APIEndpoints.tsx b/src/apps/content-editor/src/app/components/APIEndpoints.tsx new file mode 100644 index 0000000000..52b43eff1d --- /dev/null +++ b/src/apps/content-editor/src/app/components/APIEndpoints.tsx @@ -0,0 +1,89 @@ +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; +import { + MenuList, + MenuItem, + ListItemIcon, + Typography, + Chip, +} from "@mui/material"; +import { DesignServicesRounded, VisibilityRounded } from "@mui/icons-material"; + +import { AppState } from "../../../../../shell/store/types"; +import { ContentItem } from "../../../../../shell/services/types"; +import { useGetDomainsQuery } from "../../../../../shell/services/accounts"; +import { ApiType } from "../../../../schema/src/app/components/ModelApi"; + +type APIEndpointsProps = { + type: Extract; +}; +export const APIEndpoints = ({ type }: APIEndpointsProps) => { + const { itemZUID } = useParams<{ + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => state.content[itemZUID] as ContentItem + ); + const instance = useSelector((state: AppState) => state.instance); + const { data: domains } = useGetDomainsQuery(); + + const apiTypeEndpointMap: Partial> = { + "quick-access": `/-/instant/${itemZUID}.json`, + "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON", + }; + + const liveDomain = domains?.find((domain) => domain.branch == "live"); + + return ( + + { + window.open( + // @ts-expect-error config not typed + `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[type]}`, + "_blank" + ); + }} + > + + + + + {/* @ts-expect-error config not typed */} + {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[type]}`} + + + + {liveDomain && ( + { + window.open( + `https://${liveDomain.domain}${apiTypeEndpointMap[type]}`, + "_blank" + ); + }} + > + + + + + {`${liveDomain.domain}${apiTypeEndpointMap[type]}`} + + + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx index 3d4dcbd0ab..dfedaa2610 100644 --- a/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx +++ b/src/apps/content-editor/src/app/components/ContentBreadcrumbs.tsx @@ -4,7 +4,7 @@ import { useGetContentNavItemsQuery, } from "../../../../../shell/services/instance"; import { Home } from "@zesty-io/material"; -import { useHistory, useParams } from "react-router"; +import { useHistory, useParams, useLocation } from "react-router"; import { useMemo } from "react"; import { ContentNavItem } from "../../../../../shell/services/types"; import { MODEL_ICON } from "../../../../../shell/constants"; @@ -18,8 +18,12 @@ export const ContentBreadcrumbs = () => { itemZUID: string; }>(); const history = useHistory(); + const location = useLocation(); const breadcrumbData = useMemo(() => { + const isInMultipageTableView = !["new", "import"].includes( + location?.pathname?.split("/")?.pop() + ); let activeItem: ContentNavItem; const crumbs = []; @@ -52,6 +56,12 @@ export const ContentBreadcrumbs = () => { parent = null; } } + + if (!itemZUID && isInMultipageTableView) { + // Remove the model as a breadcrumb item when viewing in multipage table view + crumbs?.pop(); + } + return crumbs.map((item) => ({ node: , onClick: () => { @@ -62,7 +72,7 @@ export const ContentBreadcrumbs = () => { } }, })); - }, [nav, itemZUID]); + }, [nav, itemZUID, modelZUID, location]); return ( { + if (fields?.length) { + return fields.reduce((accu, curr) => { + if ( + !curr.deletedAt && + DYNAMIC_META_FIELD_NAMES.includes(curr.name.toLowerCase()) + ) { + accu[curr.name] = curr; + } + + return accu; + }, {}); + } + + return {}; + }, [fields]); + const activeFields = useMemo(() => { if (fields?.length) { - return fields.filter((field) => !field.deletedAt); + return fields.filter( + (field) => + !field.deletedAt && + ![ + "og_image", + "og_title", + "og_description", + "tc_title", + "tc_description", + ].includes(field.name) + ); } return []; @@ -201,6 +227,24 @@ export default memo(function Editor({ value: value, }); + if ("og_title" in metaFields) { + dispatch({ + type: "SET_ITEM_DATA", + itemZUID, + key: "og_title", + value: value, + }); + } + + if ("tc_title" in metaFields) { + dispatch({ + type: "SET_ITEM_DATA", + itemZUID, + key: "tc_title", + value: value, + }); + } + // Datasets do not get path parts if (model?.type !== "dataset") { dispatch({ @@ -231,16 +275,44 @@ export default memo(function Editor({ } if (firstContentField && firstContentField.name === name) { + // Remove tags and replace MS smart quotes with regular quotes + const cleanedValue = value + ?.replace(/<[^>]*>/g, "") + ?.replaceAll(/[\u2018\u2019\u201A]/gm, "'") + ?.replaceAll("’", "'") + ?.replaceAll(/[\u201C\u201D\u201E]/gm, '"') + ?.replaceAll("“", '"') + ?.replaceAll("”", '"') + ?.slice(0, 160); + dispatch({ type: "SET_ITEM_WEB", itemZUID, key: "metaDescription", - value: value.replace(/<[^>]*>/g, "").slice(0, 160), + value: cleanedValue, }); + + if ("og_description" in metaFields) { + dispatch({ + type: "SET_ITEM_DATA", + itemZUID, + key: "og_description", + value: cleanedValue, + }); + } + + if ("tc_description" in metaFields) { + dispatch({ + type: "SET_ITEM_DATA", + itemZUID, + key: "tc_description", + value: cleanedValue, + }); + } } } }, - [fieldErrors] + [fieldErrors, metaFields] ); const applyDefaultValuesToItemData = useCallback(() => { @@ -283,10 +355,6 @@ export default memo(function Editor({ return (
- {saveClicked && hasErrors && ( - - )} - {activeFields.length ? ( activeFields.map((field) => { return ( diff --git a/src/apps/content-editor/src/app/components/Editor/Editor.less b/src/apps/content-editor/src/app/components/Editor/Editor.less index 60565bf620..e60308712b 100644 --- a/src/apps/content-editor/src/app/components/Editor/Editor.less +++ b/src/apps/content-editor/src/app/components/Editor/Editor.less @@ -3,7 +3,6 @@ .Fields { display: flex; flex-direction: column; - height: 100%; width: 100%; scrollbar-width: none; /* FireFox */ diff --git a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx index 886b96dc93..59ddaff608 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/Field.tsx @@ -282,6 +282,7 @@ export const Field = ({ case "text": return ( setEditorType(value)} + value={value} > !!error)} /> diff --git a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx index 9be5237114..7e1032e3e4 100644 --- a/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx +++ b/src/apps/content-editor/src/app/components/Editor/Field/FieldShell.tsx @@ -282,7 +282,7 @@ const FieldLabel = memo( {(!!customTooltip || settings?.settings?.tooltip) && ( diff --git a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx index f0746e7e59..dfb8a3b700 100644 --- a/src/apps/content-editor/src/app/components/Editor/FieldError.tsx +++ b/src/apps/content-editor/src/app/components/Editor/FieldError.tsx @@ -1,4 +1,10 @@ -import { useMemo, useRef, useEffect } from "react"; +import { + useMemo, + useRef, + useEffect, + forwardRef, + useImperativeHandle, +} from "react"; import { Stack, Typography, Box, ThemeProvider } from "@mui/material"; import DangerousRoundedIcon from "@mui/icons-material/DangerousRounded"; import { theme } from "@zesty-io/material"; @@ -6,6 +12,15 @@ import { Error } from "./Field/FieldShell"; import { ContentModelField } from "../../../../../../shell/services/types"; import pluralizeWord from "../../../../../../utility/pluralizeWord"; +const SEO_FIELD_LABELS = { + metaDescription: "Meta Description", + metaTitle: "Meta Title", + metaKeywords: "Meta Keywords", + metaLinkText: "Navigation Title", + parentZUID: "Page Parent", + pathPart: "URL Path Part", +}; + type FieldErrorProps = { errors: Record; fields: ContentModelField[]; @@ -55,95 +70,115 @@ const getErrorMessage = (errors: Error) => { return errorMessages; }; -export const FieldError = ({ errors, fields }: FieldErrorProps) => { - const errorContainerEl = useRef(null); - - // Scroll to the errors on mount - useEffect(() => { - errorContainerEl?.current?.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "center", - }); - }, []); - - const fieldErrors = useMemo(() => { - const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => { - const errorMessages = getErrorMessage(errorDetails); - - const fieldData = fields?.find((field) => field.name === name); - - return { - label: fieldData?.label, - errorMessages, - sort: fieldData?.sort, - ZUID: fieldData?.ZUID, - }; - }); - - return errorMap.sort((a, b) => a.sort - b.sort); - }, [errors, fields]); - - const fieldsWithErrors = fieldErrors?.filter( - (error) => error.errorMessages.length > 0 - ); - - const handleErrorClick = (fieldZUID: string) => { - const fieldElement = document.getElementById(fieldZUID); - fieldElement?.scrollIntoView({ behavior: "smooth" }); - }; - - return ( - - - - - Item cannot be saved due to invalid field values. - - - Please correct the following {fieldsWithErrors?.length} field - {fieldsWithErrors?.length > 1 && "s"} before saving: - - - {fieldErrors?.map((error, index) => { - if (error.errorMessages.length > 0) { - return ( - - handleErrorClick(error.ZUID)} - > - {error.label} - - {error.errorMessages.length === 1 ? ( - - {error.errorMessages[0]} - ) : ( - - {error.errorMessages.map((msg, idx) => ( -
  • {msg}
  • - ))} +export const FieldError = forwardRef( + ({ errors, fields }: FieldErrorProps, ref) => { + const errorContainerEl = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + scrollToErrors() { + errorContainerEl?.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, + }; + }, + [errorContainerEl] + ); + + // Scroll to the errors on mount + useEffect(() => { + errorContainerEl?.current?.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, []); + + const fieldErrors = useMemo(() => { + const errorMap = Object.entries(errors)?.map(([name, errorDetails]) => { + const errorMessages = getErrorMessage(errorDetails); + + const fieldData = fields?.find((field) => field.name === name); + + return { + label: + fieldData?.label || + SEO_FIELD_LABELS[name as keyof typeof SEO_FIELD_LABELS], + errorMessages, + sort: fieldData?.sort, + ZUID: fieldData?.ZUID || name, + }; + }); + + return errorMap.sort((a, b) => a.sort - b.sort); + }, [errors, fields]); + + const fieldsWithErrors = fieldErrors?.filter( + (error) => error.errorMessages.length > 0 + ); + + const handleErrorClick = (fieldZUID: string) => { + const fieldElement = document.getElementById(fieldZUID); + fieldElement?.scrollIntoView({ behavior: "smooth" }); + }; + + return ( + + + + + Item cannot be saved due to invalid field values. + + + Please correct the following {fieldsWithErrors?.length} field + {fieldsWithErrors?.length > 1 && "s"} before saving: + + + {fieldErrors?.map((error, index) => { + if (error.errorMessages.length > 0) { + return ( + + handleErrorClick(error.ZUID)} + > + {error.label} - )} - - ); - } - })} - - - - ); -}; + {error.errorMessages.length === 1 ? ( + - {error.errorMessages[0]} + ) : ( + + {error.errorMessages.map((msg, idx) => ( +
  • {msg}
  • + ))} +
    + )} +
    + ); + } + })} +
    +
    +
    + ); + } +); diff --git a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx index 6ea4f8af00..56ab43a4ec 100644 --- a/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx +++ b/src/apps/content-editor/src/app/components/FieldTypeMedia.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + useCallback, + useEffect, + useMemo, + useState, + useImperativeHandle, + forwardRef, +} from "react"; import { useDispatch, useSelector } from "react-redux"; import { Box, @@ -26,7 +33,7 @@ import { } from "@mui/icons-material"; import { alpha } from "@mui/material/styles"; import { CompactView, Modal, Login } from "@bynder/compact-view"; -import { Bynder } from "@zesty-io/material"; +import { Bynder, FileReplace } from "@zesty-io/material"; import { useGetBinsQuery, @@ -43,6 +50,8 @@ import styles from "../../../../media/src/app/components/Thumbnail/Loading.less" import cx from "classnames"; import { FileTypePreview } from "../../../../media/src/app/components/FileModal/FileTypePreview"; import { useGetInstanceSettingsQuery } from "../../../../../shell/services/instance"; +import { ReplaceFileModal } from "../../../../media/src/app/components/FileModal/ReplaceFileModal"; +import { showReportDialog } from "@sentry/react"; type FieldTypeMediaProps = { images: string[]; @@ -53,283 +62,520 @@ type FieldTypeMediaProps = { hasError?: boolean; hideDrag?: boolean; lockedToGroupId: string | null; + settings?: any; }; -export const FieldTypeMedia = ({ - images, - limit, - openMediaBrowser, - onChange, - name, - hasError, - hideDrag, - lockedToGroupId, -}: FieldTypeMediaProps) => { - const [draggedIndex, setDraggedIndex] = useState(null); - const [hoveredIndex, setHoveredIndex] = useState(null); - const [localImageZUIDs, setLocalImageZUIDs] = useState(images); - const instanceId = useSelector((state: any) => state.instance.ID); - const ecoId = useSelector((state: any) => state.instance.ecoID); - const { data: bins } = useGetBinsQuery({ instanceId, ecoId }); - const defaultBin = bins?.find((bin) => bin.default); - const dispatch = useDispatch(); - const [showFileModal, setShowFileModal] = useState(""); - const [imageToReplace, setImageToReplace] = useState(""); - const [isBynderOpen, setIsBynderOpen] = useState(false); - const { data: rawInstanceSettings } = useGetInstanceSettingsQuery(); - - const bynderPortalUrlSetting = rawInstanceSettings?.find( - (setting) => setting.key === "bynder_portal_url" - ); - const bynderTokenSetting = rawInstanceSettings?.find( - (setting) => setting.key === "bynder_token" - ); - // Checks if the bynder portal and token are set - const isBynderSessionValid = - localStorage.getItem("cvrt") && localStorage.getItem("cvad"); +export const FieldTypeMedia = forwardRef( + ( + { + images, + limit, + openMediaBrowser, + onChange, + name, + hasError, + hideDrag, + lockedToGroupId, + settings, + }: FieldTypeMediaProps, + ref + ) => { + const [draggedIndex, setDraggedIndex] = useState(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + const [localImageZUIDs, setLocalImageZUIDs] = useState(images); + const instanceId = useSelector((state: any) => state.instance.ID); + const ecoId = useSelector((state: any) => state.instance.ecoID); + const { data: bins } = useGetBinsQuery({ instanceId, ecoId }); + const defaultBin = bins?.find((bin) => bin.default); + const dispatch = useDispatch(); + const [showFileModal, setShowFileModal] = useState(""); + const [imageToReplace, setImageToReplace] = useState(""); + const [isBynderOpen, setIsBynderOpen] = useState(false); + const { data: rawInstanceSettings } = useGetInstanceSettingsQuery(); + const [selectionError, setSelectionError] = useState(""); + + const bynderPortalUrlSetting = rawInstanceSettings?.find( + (setting) => setting.key === "bynder_portal_url" + ); + const bynderTokenSetting = rawInstanceSettings?.find( + (setting) => setting.key === "bynder_token" + ); + // Checks if the bynder portal and token are set + const isBynderSessionValid = + localStorage.getItem("cvrt") && localStorage.getItem("cvad"); + + useEffect(() => { + setLocalImageZUIDs(images); + }, [images]); + + useEffect(() => { + if (bynderPortalUrlSetting?.value) { + localStorage.setItem("cvad", bynderPortalUrlSetting.value); + } else { + localStorage.removeItem("cvad"); + } + }, [bynderPortalUrlSetting]); - useEffect(() => { - setLocalImageZUIDs(images); - }, [images]); + useEffect(() => { + if (bynderTokenSetting?.value) { + localStorage.setItem("cvrt", bynderTokenSetting.value); + } else { + localStorage.removeItem("cvrt"); + } + }, [bynderTokenSetting]); + + useImperativeHandle(ref, () => ({ + triggerOpenMediaBrowser() { + openMediaBrowser({ + limit, + callback: addZestyImage, + }); + }, + })); + + const addZestyImage = (selectedImages: any[]) => { + const removedImages: any[] = []; + const filteredSelectedImages = selectedImages?.filter((selectedImage) => { + //remove any images that do not match the file extension + if (settings?.fileExtensions) { + if ( + settings?.fileExtensions?.includes( + `.${fileExtension(selectedImage.filename)}` + ) + ) { + return true; + } else { + removedImages.push(selectedImage); + return false; + } + } else { + return true; + } + }); - useEffect(() => { - if (bynderPortalUrlSetting?.value) { - localStorage.setItem("cvad", bynderPortalUrlSetting.value); - } else { - localStorage.removeItem("cvad"); - } - }, [bynderPortalUrlSetting]); + if (removedImages.length) { + const filenames = removedImages.map((image) => image.filename); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); + } else { + setSelectionError(""); + } - useEffect(() => { - if (bynderTokenSetting?.value) { - localStorage.setItem("cvrt", bynderTokenSetting.value); - } else { - localStorage.removeItem("cvrt"); - } - }, [bynderTokenSetting]); + const newImageZUIDs = filteredSelectedImages?.map((image) => image.id); - const addZestyImage = (selectedImages: any[]) => { - const newImageZUIDs = selectedImages.map((image) => image.id); - // remove any duplicates - const filteredImageZUIDs = newImageZUIDs.filter( - (zuid) => !images.includes(zuid) - ); + // remove any duplicates + const filteredImageZUIDs = newImageZUIDs.filter( + (zuid) => !images.includes(zuid) + ); - onChange([...images, ...filteredImageZUIDs].join(","), name); - }; + // Do not trigger onChange if no images are added + if (![...images, ...filteredImageZUIDs]?.length) return; - const addBynderAsset = (selectedAsset: any[]) => { - if (images.length > limit) return; + onChange([...images, ...filteredImageZUIDs].join(","), name); + }; - const newBynderAssets = selectedAsset - .slice(0, limit - images.length) - .map((asset) => asset.originalUrl); - const filteredBynderAssets = newBynderAssets.filter( - (asset) => !images.includes(asset) - ); + const addBynderAsset = (selectedAsset: any[]) => { + if (images.length > limit) return; - onChange([...images, ...filteredBynderAssets].join(","), name); - }; + const removedAssets: any[] = []; + const filteredBynderAssets = selectedAsset?.filter((asset) => { + if (settings?.fileExtensions) { + const assetExtension = `.${asset.extensions[0]}`; + if (settings?.fileExtensions?.includes(assetExtension)) { + return true; + } else { + removedAssets.push(asset); + return false; + } + } else { + return true; + } + }); - const removeImage = (imageId: string) => { - const newImageZUIDs = images.filter((image) => image !== imageId); + if (removedAssets.length) { + const filenames = removedAssets.map((asset) => asset.name); + const formattedFilenames = + filenames.length > 1 + ? filenames.slice(0, -1).join(", ") + " and " + filenames.slice(-1) + : filenames[0]; + + setSelectionError( + `Could not add ${formattedFilenames}. ${settings?.fileExtensionsErrorMessage}` + ); + } else { + setSelectionError(""); + } - onChange(newImageZUIDs.join(","), name); - }; + const newBynderAssets = filteredBynderAssets + .slice(0, limit - images.length) + .map((asset) => asset.originalUrl); + const filteredBynderAssetsUrls = newBynderAssets.filter( + (asset) => !images.includes(asset) + ); - const replaceImage = (images: any[]) => { - const imageZUID = images.map((image) => image.id)?.[0]; - let imageToReplace: string; - setImageToReplace((value: string) => { - imageToReplace = value; - return ""; - }); - // if selected replacement image is already in the list of images, do nothing - if (localImageZUIDs.includes(imageZUID)) return; - const newImageZUIDs = localImageZUIDs.map((zuid) => { - if (zuid === imageToReplace) { - return imageZUID; - } + onChange([...images, ...filteredBynderAssetsUrls].join(","), name); + }; - return zuid; - }); - onChange(newImageZUIDs.join(","), name); - }; + const removeImage = (imageId: string) => { + const newImageZUIDs = images.filter((image) => image !== imageId); - const replaceBynderAsset = (selectedAsset: any) => { - // Prevent adding bynder asset that has already been added - if (localImageZUIDs.includes(selectedAsset.originalUrl)) return; + onChange(newImageZUIDs.join(","), name); + }; - const newImages = localImageZUIDs.map((image) => { - if (image === imageToReplace) { - return selectedAsset.originalUrl; + const replaceImage = (images: any[]) => { + const imageZUID = images.map((image) => image.id)?.[0]; + let imageToReplace: string; + setImageToReplace((value: string) => { + imageToReplace = value; + return ""; + }); + // if selected replacement image is already in the list of images, do nothing + if (localImageZUIDs.includes(imageZUID)) return; + // if extension is not allowed set error message + if (settings?.fileExtensions) { + if ( + !settings?.fileExtensions?.includes( + `.${fileExtension(images[0].filename)}` + ) + ) { + setSelectionError( + `Could not replace. ${settings?.fileExtensionsErrorMessage}` + ); + return; + } else { + setSelectionError(""); + } } + const newImageZUIDs = localImageZUIDs.map((zuid) => { + if (zuid === imageToReplace) { + return imageZUID; + } - return image; - }); + return zuid; + }); - setImageToReplace(""); - onChange(newImages.join(","), name); - }; + onChange(newImageZUIDs.join(","), name); + }; + + const replaceBynderAsset = (selectedAsset: any) => { + // Prevent adding bynder asset that has already been added + if (localImageZUIDs.includes(selectedAsset.originalUrl)) return; + + const assetExtension = `.${selectedAsset.extensions[0]}`; + if ( + settings?.fileExtensions && + !settings?.fileExtensions?.includes(assetExtension) + ) { + setSelectionError( + `Could not replace. ${settings?.fileExtensionsErrorMessage}` + ); + return; + } else { + setSelectionError(""); + } - const onDrop = useCallback( - (acceptedFiles: File[]) => { - if (!defaultBin) return; + const newImages = localImageZUIDs.map((image) => { + if (image === imageToReplace) { + return selectedAsset.originalUrl; + } - openMediaBrowser({ - limit, - callback: addZestyImage, + return image; }); - dispatch( - fileUploadStage( - acceptedFiles.map((file) => { - return { - file, - bin_id: defaultBin.id, - group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id, - }; - }) - ) - ); - }, - [defaultBin, dispatch, addZestyImage] - ); - - const handleReorder = () => { - const newLocalImages = [...localImageZUIDs]; - const draggedField = newLocalImages[draggedIndex]; - newLocalImages.splice(draggedIndex, 1); - newLocalImages.splice(hoveredIndex, 0, draggedField); - - setDraggedIndex(null); - setHoveredIndex(null); - setLocalImageZUIDs(newLocalImages); - onChange(newLocalImages.join(","), name); - }; + setImageToReplace(""); + onChange(newImages.join(","), name); + }; + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (!defaultBin) return; + + openMediaBrowser({ + limit, + callback: addZestyImage, + }); + + dispatch( + fileUploadStage( + acceptedFiles.map((file) => { + return { + file, + bin_id: defaultBin.id, + group_id: lockedToGroupId ? lockedToGroupId : defaultBin.id, + }; + }) + ) + ); + }, + [defaultBin, dispatch, addZestyImage] + ); - const sortedImages = useMemo(() => { - if (draggedIndex === null || hoveredIndex === null) { - return localImageZUIDs; - } else { - const newImages = [...localImageZUIDs]; - const draggedImage = newImages[draggedIndex]; - newImages.splice(draggedIndex, 1); - newImages.splice(hoveredIndex, 0, draggedImage); - return newImages; - } - }, [draggedIndex, hoveredIndex, localImageZUIDs]); + const handleReorder = () => { + const newLocalImages = [...localImageZUIDs]; + const draggedField = newLocalImages[draggedIndex]; + newLocalImages.splice(draggedIndex, 1); + newLocalImages.splice(hoveredIndex, 0, draggedField); + + setDraggedIndex(null); + setHoveredIndex(null); + setLocalImageZUIDs(newLocalImages); + onChange(newLocalImages.join(","), name); + }; + + const sortedImages = useMemo(() => { + if (draggedIndex === null || hoveredIndex === null) { + return localImageZUIDs; + } else { + const newImages = [...localImageZUIDs]; + const draggedImage = newImages[draggedIndex]; + newImages.splice(draggedIndex, 1); + newImages.splice(hoveredIndex, 0, draggedImage); + return newImages; + } + }, [draggedIndex, hoveredIndex, localImageZUIDs]); - const { getRootProps, getInputProps, open, isDragActive } = useDropzone({ - onDrop, - }); + const { getRootProps, getInputProps, open, isDragActive } = useDropzone({ + onDrop, + }); - if (!images?.length) - return ( - <> -
    evt.stopPropagation(), - onKeyDown: (evt) => evt.stopPropagation(), - })} - > - - `1px dashed ${theme.palette.primary.main}`, - borderRadius: "8px", - backgroundColor: (theme) => - alpha(theme.palette.primary.main, 0.04), - borderColor: hasError ? "error.main" : "primary.main", - }} + if (!images?.length) + return ( + <> +
    evt.stopPropagation(), + onKeyDown: (evt) => evt.stopPropagation(), + })} > - - {isDragActive ? ( - - ) : ( - - )} - + + `1px dashed ${theme.palette.primary.main}`, + borderRadius: "8px", + backgroundColor: (theme) => + alpha(theme.palette.primary.main, 0.04), + borderColor: hasError ? "error.main" : "primary.main", + }} + > + {isDragActive ? ( - "Drop your files here to Upload" + ) : ( - <> - Drag and drop your files here
    or - + )} -
    - {!isDragActive && ( - - - - {isBynderSessionValid && ( - )} - + + {isBynderSessionValid && ( + + )} + + )} +
    + + {selectionError && ( + + {selectionError} + + )} +
    + setIsBynderOpen(false)}> + + { + if (assets?.length) { + addBynderAsset(assets); + setIsBynderOpen(false); + } + }} + /> + + + + ); + + return ( + <> + + hasError ? `1px solid ${theme.palette.error.main}` : "none", + }} + > + {sortedImages.map((image, index) => { + const isBynderAsset = image.includes("bynder.com"); + + return ( + setShowFileModal(imageZUID)} + onRemove={removeImage} + onReplace={(imageZUID) => { + setImageToReplace(imageZUID); + + if (isBynderAsset) { + setIsBynderOpen(true); + } else { + openMediaBrowser({ + callback: replaceImage, + isReplace: true, + }); + } + }} + hideDrag={hideDrag || limit === 1} + isBynderAsset={isBynderAsset} + isBynderSessionValid={!!isBynderSessionValid} + /> + ); + })} + {limit > images.length && ( + + {!isBynderSessionValid && ( + )} - -
    -
    + + {isBynderSessionValid && ( + + )} + + )} + + {selectionError && ( + + {selectionError} + + )} + {showFileModal && ( + setShowFileModal("")} + currentFiles={ + sortedImages?.filter( + (image) => typeof image === "string" + ) as string[] + } + onFileChange={(fileId) => { + setShowFileModal(fileId); + }} + /> + )} setIsBynderOpen(false)}> { if (assets?.length) { - addBynderAsset(assets); + if (imageToReplace) { + replaceBynderAsset(assets[0]); + } else { + addBynderAsset(assets); + } + setIsBynderOpen(false); } }} @@ -338,123 +584,8 @@ export const FieldTypeMedia = ({ ); - - return ( - <> - - hasError ? `1px solid ${theme.palette.error.main}` : "none", - }} - > - {sortedImages.map((image, index) => { - const isBynderAsset = image.includes("bynder.com"); - - return ( - setShowFileModal(imageZUID)} - onRemove={removeImage} - onReplace={(imageZUID) => { - setImageToReplace(imageZUID); - - if (isBynderAsset) { - setIsBynderOpen(true); - } else { - openMediaBrowser({ - callback: replaceImage, - isReplace: true, - }); - } - }} - hideDrag={hideDrag || limit === 1} - isBynderAsset={isBynderAsset} - isBynderSessionValid={!!isBynderSessionValid} - /> - ); - })} - {limit > images.length && ( - - {!isBynderSessionValid && ( - - )} - - {isBynderSessionValid && ( - - )} - - )} - - {showFileModal && ( - setShowFileModal("")} - currentFiles={ - sortedImages?.filter( - (image) => typeof image === "string" - ) as string[] - } - onFileChange={(fileId) => { - setShowFileModal(fileId); - }} - /> - )} - setIsBynderOpen(false)}> - - { - if (assets?.length) { - if (imageToReplace) { - replaceBynderAsset(assets[0]); - } else { - addBynderAsset(assets); - } - - setIsBynderOpen(false); - } - }} - /> - - - - ); -}; + } +); type MediaItemProps = { imageZUID: string; @@ -462,14 +593,15 @@ type MediaItemProps = { setDraggedIndex?: (index: number) => void; setHoveredIndex?: (index: number) => void; index: number; - onPreview: (imageZUID: string) => void; - onRemove: (imageZUID: string) => void; - onReplace: (imageZUID: string) => void; + onPreview?: (imageZUID: string) => void; + onRemove?: (imageZUID: string) => void; + onReplace?: (imageZUID: string) => void; hideDrag?: boolean; isBynderAsset: boolean; isBynderSessionValid: boolean; + hideActionButtons?: boolean; }; -const MediaItem = ({ +export const MediaItem = ({ imageZUID, onReorder, setDraggedIndex, @@ -481,6 +613,7 @@ const MediaItem = ({ hideDrag, isBynderAsset, isBynderSessionValid, + hideActionButtons, }: MediaItemProps) => { const [isDragging, setIsDragging] = useState(false); const [isDraggable, setIsDraggable] = useState(false); @@ -489,6 +622,7 @@ const MediaItem = ({ skip: imageZUID?.substr(0, 4) === "http", }); const [showRenameFileModal, setShowRenameFileModal] = useState(false); + const [isReplaceFileModalOpen, setIsReplaceFileModalOpen] = useState(false); const [isCopied, setIsCopied] = useState(false); const [isCopiedZuid, setIsCopiedZuid] = useState(false); const [newFilename, setNewFilename] = useState(""); @@ -587,7 +721,7 @@ const MediaItem = ({ onClick={() => { if (isURL) return; - onPreview(imageZUID); + onPreview && onPreview(imageZUID); }} alignItems="center" sx={{ @@ -649,6 +783,7 @@ const MediaItem = ({ )} @@ -656,7 +791,9 @@ const MediaItem = ({ )} - - {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? ( - + {!hideActionButtons && ( + + {!isBynderAsset || (isBynderAsset && isBynderSessionValid) ? ( + + { + event.stopPropagation(); + onReplace && onReplace(imageZUID); + }} + > + + + + ) : ( + <> + )} + {!isURL && ( + + + + + + )} + { event.stopPropagation(); - onReplace(imageZUID); + setAnchorEl(event.currentTarget); }} > - + - ) : ( - <> - )} - {!isURL && ( - - - - - - )} - - { + { event.stopPropagation(); - setAnchorEl(event.currentTarget); + setAnchorEl(null); + }} + PaperProps={{ + style: { + width: "288px", + }, + }} + anchorOrigin={{ + vertical: "bottom", + horizontal: "right", + }} + transformOrigin={{ + vertical: "top", + horizontal: "right", }} > - - - - { - event.stopPropagation(); - setAnchorEl(null); - }} - PaperProps={{ - style: { - width: "288px", - }, - }} - anchorOrigin={{ - vertical: "bottom", - horizontal: "right", - }} - transformOrigin={{ - vertical: "top", - horizontal: "right", - }} - > - {!isURL && !isBynderAsset && ( + {!isURL && !isBynderAsset && ( + + { + event.stopPropagation(); + setAnchorEl(null); + setShowRenameFileModal(true); + }} + > + + + + Rename + + { + event.stopPropagation(); + setAnchorEl(null); + setIsReplaceFileModalOpen(true); + }} + > + + + + Replace File + + { + event.stopPropagation(); + handleCopyClick(imageZUID, true); + }} + > + + {isCopiedZuid ? : } + + Copy ZUID + + + )} { event.stopPropagation(); - setAnchorEl(null); - setShowRenameFileModal(true); + handleCopyClick(isURL ? imageZUID : data?.url, false); }} > - + {isCopied ? : } - Rename + Copy File Url - )} - {!isURL && !isBynderAsset && ( { event.stopPropagation(); - handleCopyClick(imageZUID, true); + setAnchorEl(null); + onRemove && onRemove(imageZUID); }} > - {isCopiedZuid ? : } + - Copy ZUID + Remove - )} - { - event.stopPropagation(); - handleCopyClick(isURL ? imageZUID : data?.url, false); - }} - > - - {isCopied ? : } - - Copy File Url - - { - event.stopPropagation(); - setAnchorEl(null); - onRemove(imageZUID); - }} - > - - - - Remove - - - + + + )} {showRenameFileModal && ( @@ -806,6 +957,13 @@ const MediaItem = ({ extension={fileExtension(data.filename)} /> )} + {isReplaceFileModalOpen && ( + setIsReplaceFileModalOpen(false)} + onCancel={() => setIsReplaceFileModalOpen(false)} + /> + )} ); }; diff --git a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx index 50a6889c6a..f55257c2c4 100644 --- a/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx +++ b/src/apps/content-editor/src/app/components/PendingEditsModal/PendingEditsModal.tsx @@ -44,12 +44,21 @@ export default memo(function PendingEditsModal(props: PendingEditsModalProps) { switch (action) { case "save": setLoading(true); - props.onSave().then(() => { - setLoading(false); - setOpen(false); - // @ts-ignore - answer(true); - }); + props + .onSave() + .then((i) => { + // @ts-ignore + answer(true); + }) + .catch((err) => { + console.error(err); + // @ts-ignore + answer(false); + }) + .finally(() => { + setLoading(false); + setOpen(false); + }); break; case "delete": setLoading(true); diff --git a/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx b/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx index 8a87b9525b..be65cded50 100644 --- a/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/components/AnalyticsPropertySelector.tsx @@ -1,7 +1,7 @@ import React, { useState } from "react"; import { Box, Button, Link, Skeleton } from "@mui/material"; import SettingsIcon from "@mui/icons-material/Settings"; -import { useGetAnalyticsPropertiesQuery } from "../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertiesQuery } from "../../../../../../../shell/services/analytics"; import { useGetInstanceSettingsQuery } from "../../../../../../../shell/services/instance"; import { PropertiesDialog } from "./PropertiesDialog"; diff --git a/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx b/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx index b7520248c3..10f35d322d 100644 --- a/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/components/AuthView.tsx @@ -29,7 +29,7 @@ export const AuthView = ({ validateAuth, isDashboard }: Props) => { const receiveMessage = (event: MessageEvent) => { if ( // @ts-ignore - event.origin === CONFIG.CLOUD_FUNCTIONS_DOMAIN && + event.origin === CONFIG.API_ANALYTICS && event.data.source === "zesty" ) { if (event.data.status === 200) { @@ -45,7 +45,7 @@ export const AuthView = ({ validateAuth, isDashboard }: Props) => { tabWindow?.close(); tabWindow = window.open( // @ts-ignore - `${CONFIG.CLOUD_FUNCTIONS_DOMAIN}/authenticateGoogleAnalytics?user_id=${user.ID}&account_id=${instance.ID}` + `${CONFIG.API_ANALYTICS}/ga4/auth/connect?user_id=${user.ID}&account_id=${instance.ID}` ); }; diff --git a/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx b/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx index 823f27aea4..ec1dc8b07f 100644 --- a/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/components/PropertiesDialog.tsx @@ -32,7 +32,7 @@ import googleIcon from "../../../../../../../../public/images/googleIcon.svg"; import { useDisconnectGoogleAnalyticsMutation, useGetAnalyticsPropertiesQuery, -} from "../../../../../../../shell/services/cloudFunctions"; +} from "../../../../../../../shell/services/analytics"; import { useCreateInstanceSettingsMutation, useGetInstanceSettingsQuery, @@ -98,7 +98,7 @@ export const PropertiesDialog = ({ onClose }: Props) => { const receiveMessage = (event: MessageEvent) => { if ( // @ts-ignore - event.origin === CONFIG.CLOUD_FUNCTIONS_DOMAIN && + event.origin === CONFIG.API_ANALYTICS && event.data.source === "zesty" ) { if (event.data.status === 200) { @@ -114,7 +114,7 @@ export const PropertiesDialog = ({ onClose }: Props) => { tabWindow?.close(); tabWindow = window.open( // @ts-ignore - `${CONFIG.CLOUD_FUNCTIONS_DOMAIN}/authenticateGoogleAnalytics?user_id=${user.ID}&account_id=${instance.ID}` + `${CONFIG.API_ANALYTICS}/ga4/auth/connect?user_id=${user.ID}&account_id=${instance.ID}` ); }; diff --git a/src/apps/content-editor/src/app/views/Analytics/index.tsx b/src/apps/content-editor/src/app/views/Analytics/index.tsx index 067a6e7165..d0b426b157 100644 --- a/src/apps/content-editor/src/app/views/Analytics/index.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/index.tsx @@ -3,7 +3,7 @@ import { Box } from "@mui/material"; import { ThemeProvider } from "@mui/material/styles"; import { theme } from "@zesty-io/material"; import { AuthView } from "./components/AuthView"; -import { useGetAnalyticsPropertiesQuery } from "../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertiesQuery } from "../../../../../../shell/services/analytics"; import SinglePageAnalytics from "./views/SinglePageAnalytics"; import AnalyticsDashboard from "./views/AnalyticsDashboard"; import { ContentItem } from "../../../../../../shell/services/types"; diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx index ca2d238e1f..581da2ca73 100644 --- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/GainersLosersWrapper.tsx @@ -1,5 +1,5 @@ import { Moment } from "moment-timezone"; -import { useGetAnalyticsPagePathsByFilterQuery } from "../../../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPagePathsByFilterQuery } from "../../../../../../../../../shell/services/analytics"; import { ItemsTableContent } from "./ItemsTable"; type Props = { diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx index 4870b05c9e..c52858bbaf 100644 --- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/ItemsTable.tsx @@ -11,7 +11,7 @@ import { DataGridPro, GridRenderCellParams } from "@mui/x-data-grid-pro"; import { useGetAnalyticsPropertiesQuery, useGetAnalyticsPropertyDataByQueryQuery, -} from "../../../../../../../../../shell/services/cloudFunctions"; +} from "../../../../../../../../../shell/services/analytics"; import { Moment } from "moment-timezone"; import { findTopDimensions, diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx index b06373bd10..6210d16738 100644 --- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/ItemsTable/MostPopularWrapper.tsx @@ -1,5 +1,5 @@ import { Moment } from "moment-timezone"; -import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../../shell/services/analytics"; import { findTopDimensions, generateDateRangesForReport } from "../../../utils"; import { ItemsTableContent } from "./ItemsTable"; diff --git a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx index bf49192693..d256b7c794 100644 --- a/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/views/AnalyticsDashboard/index.tsx @@ -22,7 +22,7 @@ import { AnalyticsDateFilter } from "../../components/AnalyticsDateFilter"; import { useSelector } from "react-redux"; import { AppState } from "../../../../../../../../shell/store/types"; import { Metric } from "../../components/Metric"; -import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/analytics"; import { convertSecondsToMinutesAndSeconds, findValuesForDimensions, diff --git a/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx b/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx index 44ed372d35..44dc954c37 100644 --- a/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx +++ b/src/apps/content-editor/src/app/views/Analytics/views/SinglePageAnalytics/index.tsx @@ -20,7 +20,7 @@ import { import { UsersBarChart } from "./UsersBarChart"; import { useParams as useQueryParams } from "../../../../../../../../shell/hooks/useParams"; import { useHistory, useParams } from "react-router-dom"; -import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertyDataByQueryQuery } from "../../../../../../../../shell/services/analytics"; import { convertSecondsToMinutesAndSeconds, findValuesForDimensions, @@ -28,7 +28,7 @@ import { getDateRangeAndLabelsFromParams, } from "../../utils"; import { Metric } from "../../components/Metric"; -import { useGetAnalyticsPropertiesQuery } from "../../../../../../../../shell/services/cloudFunctions"; +import { useGetAnalyticsPropertiesQuery } from "../../../../../../../../shell/services/analytics"; import instanceZUID from "../../../../../../../../utility/instanceZUID"; import { NotFound } from "../../../../../../../../shell/components/NotFound"; import WarningRoundedIcon from "@mui/icons-material/WarningRounded"; diff --git a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx index 6259b29f40..04a9b6dca6 100644 --- a/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx +++ b/src/apps/content-editor/src/app/views/ItemCreate/ItemCreate.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import useIsMounted from "ismounted"; import { useHistory, useParams } from "react-router-dom"; @@ -6,14 +6,13 @@ import isEmpty from "lodash/isEmpty"; import { createSelector } from "@reduxjs/toolkit"; import { cloneDeep } from "lodash"; -import { Divider, Box, Stack } from "@mui/material"; +import { Box, Stack, ThemeProvider, Button } from "@mui/material"; +import { theme, Brain } from "@zesty-io/material"; import { WithLoader } from "@zesty-io/core/WithLoader"; import { NotFound } from "../../../../../../shell/components/NotFound"; import { Header } from "./Header"; import { Editor } from "../../components/Editor"; -import { ItemSettings } from "../ItemEdit/Meta/ItemSettings"; -import { DataSettings } from "../ItemEdit/Meta/ItemSettings/DataSettings"; import { fetchFields } from "../../../../../../shell/store/fields"; import { createItem, @@ -35,6 +34,10 @@ import { ContentModelField, } from "../../../../../../shell/services/types"; import { SchedulePublish } from "../../../../../../shell/components/SchedulePublish"; +import { Meta } from "../ItemEdit/Meta"; +import { SocialMediaPreview } from "../ItemEdit/Meta/SocialMediaPreview"; +import { FieldError } from "../../components/Editor/FieldError"; +import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider"; export type ActionAfterSave = | "" @@ -54,7 +57,7 @@ const selectSortedModelFields = createSelector( .sort((a, b) => a.sort - b.sort) ); -type FieldError = { +type FieldErrors = { [key: string]: Error; }; @@ -77,8 +80,12 @@ export const ItemCreate = () => { const [newItemZUID, setNewItemZUID] = useState(); const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false); const [willRedirect, setWillRedirect] = useState(true); - const [fieldErrors, setFieldErrors] = useState({}); + const [fieldErrors, setFieldErrors] = useState({}); const [saveClicked, setSaveClicked] = useState(false); + // const [hasSEOErrors, setHasSEOErrors] = useState(false); + const [SEOErrors, setSEOErrors] = useState({}); + const metaRef = useRef(null); + const fieldErrorRef = useRef(null); const [ createPublishing, @@ -91,7 +98,7 @@ export const ItemCreate = () => { const { isSuccess: isSuccessNewModelFields, - isFetching: isFetchingNewModelFields, + isLoading: isFetchingNewModelFields, } = useGetContentModelFieldsQuery(modelZUID); // on mount and update modelZUID, load item fields @@ -102,7 +109,14 @@ export const ItemCreate = () => { // if item doesn't exist, generate a new one useEffect(() => { if (isEmpty(item) && !saving) { - dispatch(generateItem(modelZUID)); + const initialData = fields?.reduce((accu, curr) => { + if (!curr.deletedAt) { + accu[curr.name] = null; + } + return accu; + }, {}); + + dispatch(generateItem(modelZUID, initialData)); } }, [modelZUID, item, saving]); @@ -131,6 +145,27 @@ export const ItemCreate = () => { return hasErrors; }, [fieldErrors]); + const hasSEOErrors = useMemo(() => { + const hasErrors = Object.values(SEOErrors) + ?.map((error) => { + return Object.values(error) ?? []; + }) + ?.flat() + .some((error) => !!error); + + return hasErrors; + }, [SEOErrors]); + + const activeFields = useMemo(() => { + if (fields?.length) { + return fields.filter( + (field) => !field.deletedAt && !["og_image"].includes(field.name) + ); + } + + return []; + }, [fields]); + const loadItemFields = async (modelZUID: string) => { setLoading(true); try { @@ -148,12 +183,22 @@ export const ItemCreate = () => { const save = async (action: ActionAfterSave) => { setSaveClicked(true); - if (hasErrors) return; + metaRef.current?.validateMetaFields?.(); + if (hasErrors || hasSEOErrors) { + fieldErrorRef.current?.scrollToErrors?.(); + return; + } setSaving(true); try { - const res: any = await dispatch(createItem(modelZUID, itemZUID)); + const res: any = await dispatch( + createItem({ + modelZUID, + itemZUID, + skipPathPartValidation: model?.type === "dataset", + }) + ); if (res.err || res.error) { if (res.missingRequired || res.lackingCharLength) { const missingRequiredFieldNames: string[] = @@ -226,6 +271,7 @@ export const ItemCreate = () => { setFieldErrors(errors); // scroll to required field + fieldErrorRef.current?.scrollToErrors?.(); } if (res.error) { @@ -327,66 +373,108 @@ export const ItemCreate = () => { } message="Creating New Item" > - +
    - - - { - setFieldErrors(errors); - }} - /> - - - -

    Meta Settings

    - {model && model?.type === "dataset" ? ( - - ) : ( - + {saveClicked && (hasErrors || hasSEOErrors) && ( + + + + )} + + { + setFieldErrors(errors); + }} /> - )} + { + setSEOErrors(errors); + }} + isSaving={saving} + ref={metaRef} + errors={SEOErrors} + /> +
    -
    - + + + {model?.type !== "dataset" && ( + <> + + + + )} + + + + {isScheduleDialogOpen && !isLoadingNewItem && ( ; } - const canPublish = usePermission("PUBLISH"); - const canDelete = usePermission("DELETE"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", props.itemZUID); + const canDelete = usePermission("DELETE", props.itemZUID); + const canUpdate = usePermission("UPDATE", props.itemZUID); const domain = useDomain(); const { publishing } = props.item; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js index b6c524c769..7af50192b2 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Content/Content.js @@ -14,6 +14,7 @@ import { Actions } from "./Actions"; import { useLocalStorage } from "react-use"; import { useContext } from "react"; import { DuoModeContext } from "../../../../../../../shell/contexts/duoModeContext"; +import { FieldError } from "../../../components/Editor/FieldError"; export default function Content(props) { const [showSidebar, setShowSidebar] = useLocalStorage( @@ -71,6 +72,15 @@ export default function Content(props) { flex="0 1 auto" > + {props.saveClicked && props.hasErrors && ( + + + + )} this.setState({ makeActive: "" })} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx b/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx index e58728ade1..4bdd5ec16f 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/FreestyleWrapper.tsx @@ -2,8 +2,9 @@ import Cookies from "js-cookie"; import { MutableRefObject, forwardRef, useRef, useState } from "react"; import { useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; -import { useParams } from "react-router"; +import { useHistory, useParams } from "react-router"; import { withDAM } from "../../../../../../shell/components/withDAM"; +import { Button, Dialog } from "@mui/material"; const IframeComponent = forwardRef( (props: any, ref: MutableRefObject) => { @@ -19,6 +20,7 @@ export const FreestyleWrapper = () => { itemZUID: string; }>(); const iframeRef = useRef(null); + const history = useHistory(); const instance = useSelector((state: AppState) => state.instance); const [sessionToken] = useState(Cookies.get(CONFIG.COOKIE_NAME)); @@ -39,14 +41,30 @@ export const FreestyleWrapper = () => { ); }; + const handleFreestyleExit = () => { + history.push(`/content/${modelZUID}/${itemZUID}`); + }; + return ( - + + + + ); }; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js index ace3b56c03..c64bfe5285 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemEdit.js @@ -1,10 +1,18 @@ -import { Fragment, useEffect, useState, useMemo, createContext } from "react"; +import { + Fragment, + useEffect, + useState, + useMemo, + createContext, + useRef, +} from "react"; import { Switch, Route, Redirect, useParams, useHistory, + useLocation, } from "react-router-dom"; import useIsMounted from "ismounted"; import { useDispatch, useSelector } from "react-redux"; @@ -27,7 +35,6 @@ import { WithLoader } from "@zesty-io/core/WithLoader"; import { PendingEditsModal } from "../../components/PendingEditsModal"; import { LockedItem } from "../../components/LockedItem"; import { Content } from "./Content"; -import { Meta } from "./Meta"; import { ItemHead } from "./ItemHead"; import { NotFound } from "../NotFound"; @@ -47,6 +54,9 @@ import { import { DuoModeContext } from "../../../../../../shell/contexts/duoModeContext"; import { useLocalStorage } from "react-use"; import { FreestyleWrapper } from "./FreestyleWrapper"; +import { Meta } from "./Meta"; +import { FieldError } from "../../components/Editor/FieldError"; +import { AIGeneratorProvider } from "../../../../../../shell/components/withAi/AIGeneratorProvider"; const selectItemHeadTags = createSelector( (state) => state.headTags, @@ -70,7 +80,10 @@ export default function ItemEdit() { const dispatch = useDispatch(); const history = useHistory(); const isMounted = useIsMounted(); + const location = useLocation(); const { modelZUID, itemZUID } = useParams(); + const metaRef = useRef(null); + const fieldErrorRef = useRef(null); const item = useSelector((state) => state.content[itemZUID]); const items = useSelector((state) => state.content); const model = useSelector((state) => state.models[modelZUID]); @@ -86,6 +99,9 @@ export default function ItemEdit() { const [notFound, setNotFound] = useState(""); const [saveClicked, setSaveClicked] = useState(false); const [fieldErrors, setFieldErrors] = useState({}); + const [SEOErrors, setSEOErrors] = useState({}); + // const [hasSEOErrors, setHasSEOErrors] = useState(false); + const [headerTitle, setHeaderTitle] = useState(""); const { data: fields, isLoading: isLoadingFields } = useGetContentModelFieldsQuery(modelZUID); const [showDuoModeLS, setShowDuoModeLS] = useLocalStorage( @@ -96,12 +112,30 @@ export default function ItemEdit() { const duoModeDisabled = isFetching || instanceSettings?.find((setting) => { + // Makes sure that the CSP value is either empty or contains + // frame-ancestors 'self' zesty.io *.zesty.io anywhere in the value + const invalidCSPSettings = + setting.key === "content_security_policy" && !!setting.value + ? !setting.value.includes("frame-ancestors") || + !setting.value.includes("'self'") || + !( + setting.value.includes("zesty.io") || + setting.value.includes("*.zesty.io") + ) + : false; + // if any of these settings are present then DuoMode is unavailable return ( (setting.key === "basic_content_api_key" && setting.value) || (setting.key === "headless_authorization_key" && setting.value) || (setting.key === "authorization_key" && setting.value) || - (setting.key === "x_frame_options" && setting.value) + (setting.key === "x_frame_options" && + !!setting.value && + setting.value !== "sameorigin") || + (setting.key === "referrer_policy" && + !!setting.value && + setting.value !== "strict-origin-when-cross-origin") || + invalidCSPSettings ); }) || model?.type === "dataset"; @@ -127,6 +161,18 @@ export default function ItemEdit() { }; }, [modelZUID, itemZUID]); + useEffect(() => { + if (!loading) { + setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || ""); + } + }, [loading]); + + useEffect(() => { + setSaveClicked(false); + setFieldErrors({}); + setSEOErrors({}); + }, [location.pathname]); + const hasErrors = useMemo(() => { const hasErrors = Object.values(fieldErrors) ?.map((error) => { @@ -142,6 +188,27 @@ export default function ItemEdit() { return hasErrors; }, [fieldErrors]); + const hasSEOErrors = useMemo(() => { + const hasErrors = Object.values(SEOErrors) + ?.map((error) => { + return Object.values(error) ?? []; + }) + ?.flat() + .some((error) => !!error); + + return hasErrors; + }, [SEOErrors]); + + const activeFields = useMemo(() => { + if (fields?.length) { + return fields.filter( + (field) => !field.deletedAt && !["og_image"].includes(field.name) + ); + } + + return []; + }, [fields]); + async function lockItem() { setCheckingLock(true); try { @@ -227,11 +294,26 @@ export default function ItemEdit() { async function save() { setSaveClicked(true); - if (hasErrors) return; - - setSaving(true); try { - const res = await dispatch(saveItem(itemZUID)); + if ( + hasErrors || + hasSEOErrors || + metaRef.current?.validateMetaFields?.() + ) { + throw new Error(`Cannot Save: ${item.web.metaTitle}`); + } + + setSaving(true); + + // Skip content item fields validation when in the meta tab since this + // means that the user only wants to update the meta fields + const res = await dispatch( + saveItem({ + itemZUID, + skipContentItemValidation: + location?.pathname?.split("/")?.pop() === "meta", + }) + ); if (res.err === "VALIDATION_ERROR") { const missingRequiredFieldNames = res.missingRequired?.reduce( (acc, curr) => { @@ -299,7 +381,7 @@ export default function ItemEdit() { } setFieldErrors(errors); - return; + throw new Error(errors); } if (res.status === 400) { dispatch( @@ -308,9 +390,10 @@ export default function ItemEdit() { kind: "error", }) ); - return; + throw new Error(`Cannot Save: ${item.web.metaTitle}`); } + setHeaderTitle(item?.web?.metaTitle || item?.web?.metaLinkText || ""); dispatch( notify({ message: `Item Saved: ${ @@ -322,12 +405,13 @@ export default function ItemEdit() { // fetch new draft history dispatch(fetchAuditTrailDrafting(itemZUID)); } catch (err) { - console.error(err); // we need to set the item to dirty again because the save failed dispatch({ type: "MARK_ITEM_DIRTY", itemZUID, }); + fieldErrorRef.current?.scrollToErrors?.(); + throw new Error(err); } finally { if (isMounted.current) { setSaving(false); @@ -394,12 +478,18 @@ export default function ItemEdit() { > save().catch((err) => console.error(err))} saving={saving} hasError={Object.keys(fieldErrors)?.length} + headerTitle={headerTitle} /> ( - + + { + setSEOErrors(errors); + }} + isSaving={saving} + errors={SEOErrors} + errorComponent={ + saveClicked && + hasSEOErrors && ( + + ) + } + /> + )} /> ( - { - setFieldErrors(errors); - }} - fieldErrors={fieldErrors} - hasErrors={hasErrors} - /> + + + save().catch((err) => console.error(err)) + } + dispatch={dispatch} + loading={loading} + saving={saving} + saveClicked={saveClicked} + onUpdateFieldErrors={(errors) => { + setFieldErrors(errors); + }} + fieldErrors={fieldErrors} + hasErrors={hasErrors} + activeFields={activeFields} + fieldErrorRef={fieldErrorRef} + /> + )} /> diff --git a/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less b/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less index 56a1012a2f..b9501ddb2c 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less +++ b/src/apps/content-editor/src/app/views/ItemEdit/ItemHead/ItemHead.less @@ -5,6 +5,7 @@ flex: 1; overflow: scroll; background-color: #f9fafb; + height: 100%; // .Head { // display: flex; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx new file mode 100644 index 0000000000..11065918a3 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MatchedWords.tsx @@ -0,0 +1,105 @@ +import { Box, Stack, Typography, Chip } from "@mui/material"; +import { Check, AddRounded, RemoveRounded } from "@mui/icons-material"; +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; + +import { AppState } from "../../../../../../../../shell/store/types"; +import { cleanContent } from "./index"; +import { DYNAMIC_META_FIELD_NAMES } from "../index"; + +type MatchedWordsProps = { + uniqueNonCommonWordsArray: string[]; +}; +export const MatchedWords = ({ + uniqueNonCommonWordsArray, +}: MatchedWordsProps) => { + const { itemZUID } = useParams<{ + itemZUID: string; + }>(); + const item = useSelector((state: AppState) => state.content[itemZUID]); + const [showAll, setShowAll] = useState(false); + + const contentAndMetaWordMatches = useMemo(() => { + const textMetaFieldNames = [ + "metaDescription", + "metaTitle", + "metaKeywords", + "pathPart", + ...DYNAMIC_META_FIELD_NAMES, + ]; + + if ( + item?.web && + Object.values(item.web)?.length && + item?.data && + Object.values(item.data)?.length && + uniqueNonCommonWordsArray?.length + ) { + const metaWords = Object.entries({ ...item.web, ...item.data })?.reduce( + (accu: string[], [fieldName, value]) => { + if (textMetaFieldNames.includes(fieldName) && !!value) { + // Replace all new line characters with a space and remove all special characters + const cleanedValue = cleanContent(value); + + accu = [...accu, ...cleanedValue?.split(" ")]; + + return accu; + } + + return accu; + }, + [] + ); + + const uniqueMetaWords = Array.from(new Set(metaWords)); + + return uniqueMetaWords.filter((metaWord) => + uniqueNonCommonWordsArray.includes(metaWord) + ); + } + + return []; + }, [uniqueNonCommonWordsArray, item?.web, item?.data]); + + return ( + + + Content and Meta Matched Words + + + {!contentAndMetaWordMatches?.length && ( + + No Matching Words + + )} + {contentAndMetaWordMatches + ?.slice(0, showAll ? undefined : 9) + ?.map((word) => ( + } + variant="outlined" + /> + ))} + {contentAndMetaWordMatches?.length > 10 && ( + + ) : ( + + ) + } + onClick={() => setShowAll(!showAll)} + /> + )} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx new file mode 100644 index 0000000000..0104ae3f4a --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/MostMentionedWords.tsx @@ -0,0 +1,124 @@ +import { + Box, + Stack, + Typography, + Chip, + TextField, + InputAdornment, +} from "@mui/material"; +import { Search, AddRounded, RemoveRounded } from "@mui/icons-material"; +import { useMemo, useState } from "react"; +import { COMMON_WORDS } from "."; + +type MostMentionedWordsProps = { + wordsArray: string[]; +}; +export const MostMentionedWords = ({ wordsArray }: MostMentionedWordsProps) => { + const [filterKeyword, setFilterKeyword] = useState(""); + const [showAll, setShowAll] = useState(false); + + const wordCount = useMemo(() => { + if (!!wordsArray?.length) { + const wordsWithCount = wordsArray?.reduce( + (accu: Record, word) => { + if (!COMMON_WORDS.includes(word)) { + if (word in accu) { + accu[word] += 1; + } else { + accu[word] = 1; + } + } + + return accu; + }, + {} + ); + + return Object.entries(wordsWithCount ?? {}) + ?.filter(([, count]) => count > 1) + ?.sort(([, a], [, b]) => b - a); + } + + return []; + }, [wordsArray]); + + const filteredWords = useMemo(() => { + if (!!filterKeyword) { + return wordCount?.filter(([word]) => + word.includes(filterKeyword.toLowerCase().trim()) + ); + } + + return wordCount; + }, [filterKeyword, wordCount]); + + return ( + + + + Most Mentioned Words in Content Item + + + Check that your focus keywords are occurring a minimum 2 times. + + + setFilterKeyword(evt.target.value)} + size="small" + placeholder="Filter words" + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {!filteredWords?.length && !filterKeyword && ( + + No words in your content item occur 2 or more times + + )} + {filteredWords + ?.slice(0, showAll ? undefined : 9) + ?.map(([word, count]) => ( + + {word} + + {count} + + + } + size="small" + variant="outlined" + /> + ))} + {filteredWords?.length > 10 && ( + + ) : ( + + ) + } + onClick={() => setShowAll(!showAll)} + /> + )} + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx new file mode 100644 index 0000000000..61619ab16f --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/WordCount.tsx @@ -0,0 +1,62 @@ +import { Stack, Box, Typography, Divider } from "@mui/material"; + +type WordCountProps = { + totalWords: number; + totalUniqueWords: number; + totalUniqueNonCommonWords: number; +}; +export const WordCount = ({ + totalWords, + totalUniqueWords, + totalUniqueNonCommonWords, +}: WordCountProps) => { + return ( + + + + Words + + + {totalWords} + + + + + + Unique Words + + + {totalUniqueWords} + + + + + + Non Filler Words + + + {totalUniqueNonCommonWords} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx new file mode 100644 index 0000000000..218b70f963 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ContentInsights/index.tsx @@ -0,0 +1,247 @@ +import { useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useParams } from "react-router"; + +import { AppState } from "../../../../../../../../shell/store/types"; +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; +import { WordCount } from "./WordCount"; +import { MatchedWords } from "./MatchedWords"; +import { MostMentionedWords } from "./MostMentionedWords"; +import { DYNAMIC_META_FIELD_NAMES } from "../index"; + +export const COMMON_WORDS: Readonly = [ + "null", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + "0", + "our", + "one", + "two", + "three", + "four", + "five", + "I'm", + "that's", + "it's", + "aren't", + "we've", + "i've", + "didn't", + "don't", + "you'll", + "you're", + "we're", + "Here's", + "about", + "actually", + "always", + "even", + "given", + "into", + "just", + "not", + "Im", + "thats", + "its", + "arent", + "weve", + "ive", + "didnt", + "dont", + "the", + "of", + "to", + "and", + "a", + "in", + "is", + "it", + "you", + "that", + "he", + "was", + "for", + "on", + "are", + "with", + "as", + "I", + "his", + "they", + "be", + "at", + "one", + "have", + "this", + "from", + "or", + "had", + "by", + "but", + "some", + "what", + "there", + "we", + "can", + "out", + "were", + "all", + "your", + "when", + "up", + "use", + "how", + "said", + "an", + "each", + "she", + "which", + "do", + "their", + "if", + "will", + "way", + "many", + "then", + "them", + "would", + "like", + "so", + "these", + "her", + "see", + "him", + "has", + "more", + "could", + "go", + "come", + "did", + "my", + "no", + "get", + "me", + "say", + "too", + "here", + "must", + "such", + "try", + "us", + "own", + "oh", + "any", + "youll", + "youre", + "also", + "than", + "those", + "though", + "thing", + "things", +] as const; + +const findMatch = (needle: string, haystack: string[]) => { + let truth = false; + haystack.forEach((word) => { + if (word.toLowerCase() == needle.toLowerCase()) truth = true; + }); + return truth; +}; + +export const cleanContent = (string: string) => { + return string + ?.replaceAll(/(\d-(.*?)-(.*?))(,| )/gi, "") // Remove zuids + ?.replaceAll(/(<([^>]+)>)/gi, "") // Remove html tags + ?.replaceAll(/(&(.*?);)/gi, " ") // Remove encoded characters + ?.replaceAll(/[^a-zA-Z0-9\s']|(? { + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const item = useSelector((state: AppState) => state.content[itemZUID]); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, + }); + + const textFieldNames = useMemo(() => { + if (modelFields?.length) { + const textFieldTypes = [ + "text", + "wysiwyg_basic", + "wysiwyg_advanced", + "article_writer", + "markdown", + "textarea", + ]; + + return modelFields.reduce((accu: string[], curr) => { + if ( + textFieldTypes.includes(curr.datatype) && + !curr.deletedAt && + !DYNAMIC_META_FIELD_NAMES.includes(curr.name) + ) { + accu = [...accu, curr.name]; + return accu; + } + + return accu; + }, []); + } + }, [modelFields]); + + const contentItemWordsArray = useMemo(() => { + if ( + item?.data && + Object.values(item.data)?.length && + textFieldNames?.length + ) { + let words: string[] = []; + + textFieldNames.forEach((fieldName) => { + let value = item?.data[fieldName]; + + if (!!value) { + value = cleanContent(String(value)); + + words = [...words, ...value.split(" ")]; + } + }); + + return words; + } + + return []; + }, [textFieldNames, item?.data]); + + const uniqueWordsArray = Array.from(new Set(contentItemWordsArray)); + const uniqueNonCommonWordsArray = Array.from( + uniqueWordsArray?.filter((word) => !COMMON_WORDS.includes(word)) + ); + + return ( + <> + + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js deleted file mode 100644 index 4fcaee4823..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.js +++ /dev/null @@ -1,417 +0,0 @@ -import React, { useState } from "react"; - -import Divider from "@mui/material/Divider"; -import Button from "@mui/material/Button"; - -import Card from "@mui/material/Card"; -import CardHeader from "@mui/material/CardHeader"; -import CardContent from "@mui/material/CardContent"; -import SavedSearchIcon from "@mui/icons-material/SavedSearch"; - -import { faCheck, faSearchDollar } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import cx from "classnames"; - -import styles from "./ContentInsights.less"; -export function ContentInsights(props) { - const [showAllWords, setShowAllWords] = useState(false); - - // clean up functions - const stripTags = (string) => { - return string.replace(/(<([^>]+)>)/gi, ""); - }; - const stripEncoded = (string) => { - return string.replace(/(&(.*?);)/gi, " "); - }; - const stripHidden = (string) => { - return string.replace(/(\r|\n|\t)/gi, " "); - }; - const stripZUIDs = (string) => { - return string.replace(/(\d-(.*?)-(.*?))(,| )/gi, " "); - }; - const stripPunctuation = (string) => { - return string.replace(/("|,|:|;|\. |!)/gi, " "); - }; - const stripDoubleSpace = (string) => { - return string.replace(/\s\s+/g, " "); - }; - const stripDashesAndSlashes = (string) => { - return string.replace(/-|\//g, " "); - }; - const commonwords = [ - "null", - "1", - "2", - "3", - "4", - "5", - "6", - "7", - "8", - "9", - "0", - "our", - "one", - "two", - "three", - "four", - "five", - "I'm", - "that's", - "it's", - "aren't", - "we've", - "i've", - "didn't", - "don't", - "you'll", - "you're", - "we're", - "Here's", - "about", - "actually", - "always", - "even", - "given", - "into", - "just", - "not", - "Im", - "thats", - "its", - "arent", - "weve", - "ive", - "didnt", - "dont", - "the", - "of", - "to", - "and", - "a", - "in", - "is", - "it", - "you", - "that", - "he", - "was", - "for", - "on", - "are", - "with", - "as", - "I", - "his", - "they", - "be", - "at", - "one", - "have", - "this", - "from", - "or", - "had", - "by", - "but", - "some", - "what", - "there", - "we", - "can", - "out", - "were", - "all", - "your", - "when", - "up", - "use", - "how", - "said", - "an", - "each", - "she", - "which", - "do", - "their", - "if", - "will", - "way", - "many", - "then", - "them", - "would", - "like", - "so", - "these", - "her", - "see", - "him", - "has", - "more", - "could", - "go", - "come", - "did", - "my", - "no", - "get", - "me", - "say", - "too", - "here", - "must", - "such", - "try", - "us", - "own", - "oh", - "any", - "youll", - "youre", - "also", - "than", - "those", - "though", - "thing", - "things", - ]; - - const findMatch = (needle, haystack) => { - let truth = false; - haystack.forEach((word) => { - if (word.toLowerCase() == needle.toLowerCase()) truth = true; - }); - return truth; - }; - - let combinedString = ""; - let wordCount = {}; - let metaWordCount = {}; - - // Working with Content - // Content: combine all the text content we find from the item - for (const [key, value] of Object.entries(props.content)) { - combinedString += " " + value; - } - - // Content: clean the string up by remove values that are not considered word content - combinedString = stripDoubleSpace( - stripPunctuation( - stripHidden(stripEncoded(stripTags(stripZUIDs(combinedString)))) - ) - ); - combinedString = combinedString.toLowerCase(); - - // Meta: build combined string - let combinedMetaString = - props.meta.metaTitle + - " " + - props.meta.path + - " " + - props.meta.metaDescription; - // Meta: clean the string - combinedMetaString = stripDoubleSpace( - stripPunctuation(stripDashesAndSlashes(combinedMetaString.toLowerCase())) - ); - - // Content: get total word counts with initial split - let splitWords = combinedString.split(" "); - let totalWords = splitWords.length; - - // Content & Meta: remove common words - commonwords.forEach((commonWord) => { - let re = new RegExp("\\b" + commonWord.toLowerCase() + "\\b", "ig"); - combinedString = combinedString.replace(re, ""); - combinedMetaString = combinedMetaString.replace(re, ""); - }); - // Content: strip left over double spaces - combinedString = stripDoubleSpace(combinedString); - splitWords = combinedString.split(" "); - let totalNonCommonWords = splitWords.length; - - // Content: use split words to tally total count of numbers - splitWords.forEach((word) => { - if (!wordCount.hasOwnProperty(word)) { - wordCount[word] = 1; - } else { - wordCount[word]++; - } - }); - - // Meta: use split meta words to tally total count of numbers - let splitMetaWords = combinedMetaString.split(" "); - splitMetaWords.forEach((word) => { - if (!metaWordCount.hasOwnProperty(word)) { - metaWordCount[word] = 1; - } else { - metaWordCount[word]++; - } - }); - // Meta: Build Word Lists for output - let metaWordArray = []; - for (const [key, value] of Object.entries(metaWordCount)) { - if (key != "") { - metaWordArray.push({ - word: key, - count: value, - match: findMatch(key, splitWords), - }); - } - } - // Content: Sort Word List by more occuring - metaWordArray = metaWordArray.sort((a, b) => { - return b.count - a.count; - }); - - // Content: Build Word Lists for output - let wordArray = []; - for (const [key, value] of Object.entries(wordCount)) { - if (key != "") { - wordArray.push({ - word: key, - count: value, - match: findMatch(key, splitMetaWords), - }); - } - } - let totalUniqueNonCommonWords = wordArray.length; - // Content: Sort Word List by more occuring - wordArray = wordArray.sort((a, b) => { - return b.count - a.count; - }); - - return ( - - } - title="Content Insights" - sx={{ - backgroundColor: "grey.100", - }} - titleTypographyProps={{ - sx: { - color: "text.primary", - }, - }} - > - -
    -
    - Total Words {totalWords} -
    -
    - Non-Common* Words {totalNonCommonWords} -
    -
    - Unique Words* {totalUniqueNonCommonWords} -
    -
    - -

    Content and Meta Matched Words

    -
    - {metaWordArray.map((item, i) => { - if (item.match) { - return ( -
    - - - - {item.word} -
    - ); - } - })} -
    - -

    - Word occurrences from this Content Item (not the fully rendered page) -

    -
    - {wordArray.map((item, i) => { - if (item.count > 1) { - return ( -
    - {item.count} - {item.word} -
    - ); - } - })} - {!showAllWords && ( - - )} - {showAllWords && ( - - )} -
    - {showAllWords && ( -
    - {wordArray.map((item, i) => { - if (item.count == 1) { - return ( -
    - {item.count} - {item.word} -
    - ); - } - })} -
    - )} - -

    Word occurrences from the URL, Meta Title and Description

    -
    - {metaWordArray.map((item, i) => { - return ( -
    - {item.count} - {item.word} -
    - ); - })} -
    -
    -
    - ); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less deleted file mode 100644 index 394af8d17c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/ContentInsights.less +++ /dev/null @@ -1,83 +0,0 @@ -@import "~@zesty-io/core/typography.less"; -@import "~@zesty-io/core/colors.less"; -@roundPx: 6px; -.ContentInsights { - .toggleButton { - padding: 0px 10px !important; - margin-bottom: auto; - } - .level { - display: flex; - div { - flex: 1; - text-align: center; - background-color: lighten(@zesty-light-gray, 15%); - border: 1px lighten(@zesty-light-gray, 15%) solid; - color: @zesty-gray; - border-radius: 4px; - margin-right: 8px; - overflow: hidden; - padding-top: 4px; - &:last-child { - margin-right: 0px; - } - span { - display: block; - font-size: 2em; - background: white; - color: @zesty-dark-blue; - padding: 8px 0px; - margin-top: 4px; - } - } - } - .wordBank { - display: flex; - flex-direction: row; - flex-wrap: wrap; - margin-top: 8px; - .wordGroup { - display: flex; - width: min-content; - font-size: 1em; - margin-right: 8px; - margin-bottom: 8px; - border: 1px lighten(@zesty-light-gray, 15%) solid; - border-radius: @roundPx; - overflow: hidden; - - strong { - width: 20px; - flex: 1; - display: inline-block; - background: lighten(@zesty-light-gray, 15%); - color: @zesty-dark-blue; - - padding: 5px 8px; - font-weight: 600; - } - span { - flex: 5; - flex-direction: column; - flex-basis: 100%; - display: inline-block; - color: @zesty-dark-blue; - background: white; - overflow: hidden; - text-overflow: ellipsis; - - padding: 5px 8px; - } - &.hidden { - display: none; - } - &.matched { - border: 1px lighten(@zesty-green, 20%) solid; - strong { - background: lighten(@zesty-green, 20%); - color: darken(@zesty-green, 20%); - } - } - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js deleted file mode 100644 index 9ac2579447..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ContentInsights/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ContentInsights } from "./ContentInsights"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js deleted file mode 100644 index f8796526e4..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/DataSettings.js +++ /dev/null @@ -1,48 +0,0 @@ -import { Component } from "react"; -import cx from "classnames"; - -import { MetaTitle } from "./settings/MetaTitle"; -import MetaDescription from "./settings/MetaDescription"; -import { MetaKeywords } from "./settings/MetaKeywords"; -import { MetaLinkText } from "./settings/MetaLinkText"; -import { Stack } from "@mui/material"; - -import styles from "./ItemSettings.less"; -export class DataSettings extends Component { - onChange = (value, name) => { - if (!name) { - throw new Error("Input is missing name attribute"); - } - this.props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: this.props.item.meta.ZUID, - key: name, - value: value, - }); - }; - - render() { - let web = this.props.item.web || {}; - return ( -
    -
    - - - - - - -
    -
    - ); - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js deleted file mode 100644 index ca4d408705..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.js +++ /dev/null @@ -1,227 +0,0 @@ -import { - memo, - Fragment, - useCallback, - useMemo, - useState, - useEffect, -} from "react"; -import { useSelector } from "react-redux"; -import { useDispatch } from "react-redux"; - -import { MetaTitle } from "./settings/MetaTitle"; -import MetaDescription from "./settings/MetaDescription"; -import { MetaKeywords } from "./settings/MetaKeywords"; -import { MetaLinkText } from "./settings/MetaLinkText"; -import { ItemRoute } from "./settings/ItemRoute"; -import { ContentInsights } from "./ContentInsights"; -import { ItemParent } from "./settings/ItemParent"; -import { CanonicalTag } from "./settings/CanonicalTag"; -import { SitemapPriority } from "./settings/SitemapPriority"; -import { useDomain } from "shell/hooks/use-domain"; - -import Card from "@mui/material/Card"; -import CardHeader from "@mui/material/CardHeader"; -import CardContent from "@mui/material/CardContent"; -import SearchIcon from "@mui/icons-material/Search"; - -import styles from "./ItemSettings.less"; -import { fetchGlobalItem } from "../../../../../../../../shell/store/content"; - -export const MaxLengths = { - metaLinkText: 150, - metaTitle: 150, - metaDescription: 160, - metaKeywords: 255, -}; - -export const ItemSettings = memo( - function ItemSettings(props) { - const showSiteNameInMetaTitle = useSelector( - (state) => - state.settings.instance.find( - (setting) => setting.key === "show_in_title" - )?.value - ); - const dispatch = useDispatch(); - const domain = useDomain(); - let { data, meta, web } = props.item; - const [errors, setErrors] = useState({}); - - data = data || {}; - meta = meta || {}; - web = web || {}; - - const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); - - const onChange = useCallback( - (value, name) => { - if (!name) { - throw new Error("Input is missing name attribute"); - } - - if (MaxLengths[name]) { - setErrors({ - ...errors, - [name]: { - EXCEEDING_MAXLENGTH: - value?.length > MaxLengths[name] - ? value?.length - MaxLengths[name] - : 0, - }, - }); - } - - props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: meta.ZUID, - key: name, - value: value, - }); - }, - [meta.ZUID, errors] - ); - - useEffect(() => { - if (props.saving) { - setErrors({}); - return; - } - }, [props.saving]); - - return ( -
    -
    - {web.pathPart !== "zesty_home" && ( - - - - - )} - - - - - - {props.item && ( - - )} -
    - -
    - ); - }, - (prevProps, nextProps) => { - // NOTE We want to update children when the `item` changes but only when `content` length changes - - // If the model we are viewing changes we need to re-render - if (prevProps.modelZUID !== nextProps.modelZUID) { - return false; - } - - // If the item referential equailty of the item changes we want to re-render - // should mean the item has updated data - if (prevProps.item !== nextProps.item) { - return false; - } - - // Avoid referential equality check and compare content length to see if new ones where added - let prevItemsLen = Object.keys(prevProps["content"]).length; - let nextItemsLen = Object.keys(nextProps["content"]).length; - if (prevItemsLen !== nextItemsLen) { - return false; - } - - /** - * We ignore changes to the `instance` object and `dispatch` - * as these values should not change. - */ - - return true; - } -); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less deleted file mode 100644 index 3f0eb24a4f..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/ItemSettings.less +++ /dev/null @@ -1,79 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -@import "~@zesty-io/core/typography.less"; - -.Meta { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 32px; - // flex-direction: row; - // margin: 32px; - - .DataSettings { - flex: 0.6; - } - - .MetaMain { - flex: 3; - // margin-right: 32px; - min-width: 0px; - - &.DataSettings { - margin-right: 0; - } - - article { - margin: 16px 0; - } - } - - .MetaSide { - flex: 2; - margin: 16px 0; - min-width: 0px; - - .SearchResult { - border-radius: 5px; - background: white; - border: 1px @zesty-light-gray solid; - padding: 16px; - - .GoogleTitle { - font-family: Roboto, "Gibson", arial, sans-serif; - color: #1a0dab; // sampled from google - word-wrap: break-word; - font-size: 18px; - line-height: 21.6px; - font-weight: 400; - } - .GoogleLink { - color: #006621; // sampled from google - display: block; - font-family: Roboto, arial, sans-serif; - font-size: 14px; - font-style: normal; - font-weight: 400; - height: auto; - line-height: 16.8px; - padding: 2px 0 3px 0; - - a { - text-decoration: none; - color: #006621; // sampled from google - } - - span.Icon { - margin-left: 5px; - } - } - .GoogleDesc { - word-wrap: break-word; - color: rgb(84, 84, 84); - font-family: Roboto, arial, sans-serif; - font-size: 13px; - font-weight: 400; - height: auto; - line-height: 18.2px; - } - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js deleted file mode 100644 index 57750330fa..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/index.js +++ /dev/null @@ -1 +0,0 @@ -export { ItemSettings } from "./ItemSettings"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js deleted file mode 100644 index c6748bec6c..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.js +++ /dev/null @@ -1,263 +0,0 @@ -import { memo, Fragment, useState, useEffect } from "react"; -import { connect, useSelector } from "react-redux"; -import { debounce, uniqBy } from "lodash"; -import { notify } from "shell/store/notifications"; - -import { Select, Option } from "@zesty-io/core/Select"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faHome } from "@fortawesome/free-solid-svg-icons"; - -import { searchItems } from "shell/store/content"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; - -import styles from "./ItemParent.less"; -export const ItemParent = connect((state) => { - return { - nav: state.navContent.raw, - }; -})( - memo( - function ItemParent(props) { - const items = useSelector((state) => state.content); - const [loading, setLoading] = useState(false); - const [parent, setParent] = useState({ - meta: { - ZUID: "0", // "0" = root level route - path: "/", - }, - }); - - const [parents, setParents] = useState( - parentOptions(props.currentItemLangID, props.path, items) - ); - - const onSearch = debounce((term) => { - if (term) { - setLoading(true); - props.dispatch(searchItems(term)).then((res) => { - setLoading(false); - setParents( - parentOptions(props.currentItemLangID, props.path, { - ...items, - // needs to reduce and converts this data as the same format of the items to - // prevent having an issue on having an itemZUID with an incorrect format - // the reason is that the item has a format of {[itemZUID]:data} - // while the res.data has a value of an array which cause the needs of converting - // the response to an object with a zuid as a key - ...res?.data.reduce((acc, curr) => { - return { ...acc, [curr.meta.ZUID]: curr }; - }, {}), - }) - ); - }); - } - }, 250); - - /** - * Recurse nav linked list to find current items parent - * @param {*} zuid - * @param {*} count - */ - const findNavParent = (zuid, count = 0) => { - count++; - const navEntry = props.nav.find((el) => el.ZUID === zuid); - if (navEntry) { - // This first item should be the model we are resolving for so - // continue on up the nav tree - if (count === 0) { - return findNavParent(navEntry.parentZUID, count); - } else { - if (navEntry.type === "item") { - return navEntry; - } else if (navEntry.parentZUID) { - return findNavParent(navEntry.parentZUID, count); - } else { - return { ZUID: "0" }; - } - } - } else { - return { ZUID: "0" }; - } - }; - - useEffect(() => { - let parentZUID = props.parentZUID; - - // If it's a new item chase down the parentZUID within navigation - // This way we avoid an API request - if (props.itemZUID && props.itemZUID.slice(0, 3) === "new") { - const result = findNavParent(props.modelZUID); - - // change for preselection - parentZUID = result.ZUID; - - // Update redux store so if the item is saved we know it's parent - props.onChange(parentZUID, "parentZUID"); - } - - // Try to preselect parent - if (parentZUID && parentZUID != "0" && parentZUID !== null) { - const item = items[parentZUID]; - if (item && item.meta && item.meta.ZUID && item.meta.path) { - setParent(item); - } else { - props.dispatch(searchItems(parentZUID)).then((res) => { - if (res) { - if (res.data) { - if (Array.isArray(res.data) && res.data.length) { - // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but - // there are some old models that still have the homepage as their parent models. - if (res.data[0]?.web?.path === "/") { - setParent({ - meta: { - ZUID: "0", // "0" = root level route - path: "/", - }, - }); - } else { - setParent(res.data[0]); - /** - * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content` - * we can't use array length comparision to determine a new parent has been added. Also since updates to the item - * currently being edited cause a new `content` object to be created in it's reducer we can't use - * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected - * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the - * API to allow it to be pre-selected while avoiding re-renders on changes to this item. - */ - - setParents( - parentOptions(props.currentItemLangID, props.path, { - ...items, - [res.data[0].meta.ZUID]: res.data[0], - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - heading: `Cannot Save: ${props.metaTitle}`, - messsage: "Set page parent in SEO Tab", - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return data. Try Again.`, - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - heading: `"Cannot Save: ${props.metaTitle}`, - message: `Page's Parent does not exist or has been deleted`, - }) - ); - } - }); - } - } - }, []); - - return ( -
    - - {/* - Delay rendering select until we have a parent. - Sometimes we have to resolve the parent from the API asynchronously - */} - {!parent ? ( - "Loading item parent" - ) : ( - - )} - -
    - ); - }, - (prevProps, nextProps) => { - let isEqual = true; - - // Shallow compare all props - Object.keys(prevProps).forEach((key) => { - // ignore content, we'll check it seperately after - if (key !== "content") { - if (prevProps[key] !== nextProps[key]) { - isEqual = false; - } - } - }); - - return isEqual; - } - ) -); - -function parentOptions(currentItemLangID, path, items) { - const options = Object.entries(items) - ?.reduce((acc, [itemZUID, itemData]) => { - if ( - itemZUID.slice(0, 3) !== "new" && // Exclude new items - itemData?.meta?.ZUID && // must have a ZUID - itemData?.web?.path && // must have a path - itemData?.web.path !== "/" && // Exclude homepage - itemData?.web.path !== path && // Exclude current item - itemData?.meta?.langID === currentItemLangID // display only relevant language options - ) { - acc.push({ - value: itemZUID, - text: itemData.web.path, - }); - } - - return acc; - }, []) - .sort((a, b) => { - if (a.text > b.text) { - return 1; - } else if (a.text < b.text) { - return -1; - } else { - return 0; - } - }); - - return uniqBy(options, "value"); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less deleted file mode 100644 index c088a932ec..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemParent.less +++ /dev/null @@ -1,9 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.ItemParent { - label { - display: flex; - color: @zesty-dark-blue; - font-size: 14px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js deleted file mode 100644 index da3fc8c513..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.js +++ /dev/null @@ -1,186 +0,0 @@ -import { memo, Fragment, useCallback, useState, useEffect } from "react"; -import { connect, useDispatch } from "react-redux"; -import debounce from "lodash/debounce"; -import { searchItems } from "shell/store/content"; -import { notify } from "shell/store/notifications"; - -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faSpinner, faCheck, faTimes } from "@fortawesome/free-solid-svg-icons"; -import TextField from "@mui/material/TextField"; -import { InputIcon } from "@zesty-io/core/InputIcon"; -import { AppLink } from "@zesty-io/core/AppLink"; - -import styles from "./ItemRoute.less"; -import { withCursorPosition } from "../../../../../../../../../shell/components/withCursorPosition"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -const TextFieldWithCursorPosition = withCursorPosition(TextField); - -export const ItemRoute = connect((state) => { - return { - content: state.content, - }; -})( - memo(function ItemRoute(props) { - const dispatch = useDispatch(); - const [pathPart, setPathPart] = useState(props.path_part); - const [loading, setLoading] = useState(false); - const [unique, setUnique] = useState(true); - - const validate = useCallback( - debounce((path) => { - if (!path) { - setUnique(false); - return; - } - - const parent = props.content[props.parentZUID]; - const fullPath = parent ? parent.web.path + path : path; - - setLoading(true); - - return dispatch(searchItems(fullPath)) - .then((res) => { - if (res) { - if (res.data) { - if (Array.isArray(res.data) && res.data.length) { - // check list of partial matches for exact path match - const matches = res.data.filter((item) => { - /** - * Exclude currently viewed item zuid, as it's currently saved path would match. - * Check if other results have a matching path, if so then it is already taken and - * can not be used. - * Result paths come with leading and trailing slashes - */ - return ( - item.meta.ZUID !== props.ZUID && - item.web.path === "/" + fullPath + "/" - ); - }); - if (matches.length) { - props.dispatch( - notify({ - kind: "warn", - message: ( -

    - URL {matches[0].web.path} is - unavailable. Used by  - - {matches[0].web.metaLinkText || - matches[0].web.metaTitle} - -

    - ), - }) - ); - } - - setUnique(!matches.length); - } else { - setUnique(true); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return data ${res.status}`, - }) - ); - } - } else { - props.dispatch( - notify({ - kind: "warn", - message: `API failed to return response ${res.status}`, - }) - ); - } - }) - .finally(() => setLoading(false)); - }, 500), - [props.parentZUID] - ); - - const onChange = (evt) => { - // All URLs are lowercased - // Replace ampersand characters with 'and' - // Only allow alphanumeric characters - const path = evt.target.value - .trim() - .toLowerCase() - .replace(/\&/g, "and") - .replace(/[^a-zA-Z0-9]/g, "-"); - - validate(path); - setPathPart(path); - - props.dispatch({ - type: "SET_ITEM_WEB", - itemZUID: props.ZUID, - key: "pathPart", - value: path, - }); - }; - - // update internal state if external path part changes - useEffect(() => { - if (props.path_part) { - validate(props.path_part); - setPathPart(props.path_part); - } - }, [props.path_part, props.parentZUID]); - - return ( -
    - -
    - {props.path_part === "zesty_home" ? ( -

    - -  Homepage -

    - ) : ( - - - - {loading && ( - - - - )} - - {!loading && unique && ( - - - - )} - - {!loading && !unique && ( - - - - )} - - )} -
    -
    -
    - ); - }) -); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less deleted file mode 100644 index c35a9c0d31..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/ItemRoute.less +++ /dev/null @@ -1,39 +0,0 @@ -@import "~@zesty-io/core/keyframes.less"; -@import "~@zesty-io/core/colors.less"; - -.ItemRoute { - label { - display: flex; - color: @zesty-dark-blue; - font-size: 14px; - } - .Path { - display: flex; - .Parent { - border-radius: 3px; - background-color: #4c5567; - align-items: center; - display: flex; - padding: 0 8px; - color: #fff; - } - .Checking { - margin: 0; - background-color: #404759; - border: 1px solid #404759; - i { - animation: spin 2s linear infinite; - } - } - .Valid { - margin: 0; - background-color: #63a41e; - border: 1px solid #63a41e; - } - .Invalid { - margin: 0; - background-color: #9a2803; - border: 1px solid #9a2803; - } - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js deleted file mode 100644 index 9d786d1385..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.js +++ /dev/null @@ -1,86 +0,0 @@ -import { useState, useEffect } from "react"; -import { connect } from "react-redux"; -import { TextField } from "@mui/material"; - -import { notify } from "shell/store/notifications"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; - -import styles from "./MetaDescription.less"; -export default connect()(function MetaDescription({ - meta_description, - onChange, - dispatch, - errors, -}) { - const [error, setError] = useState(""); - - useEffect(() => { - if (meta_description) { - let message = ""; - - if (!(meta_description.indexOf("\u0152") === -1)) { - message = - "Found OE ligature. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\u0153") === -1)) { - message = - "Found oe ligature. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\xAB") === -1)) { - message = - "Found << character. These special characters are not allowed in meta descriptions."; - } else if (!(meta_description.indexOf("\xBB") === -1)) { - message = - "Found >> character. These special characters are not allowed in meta descriptions."; - } else if (/[\u201C\u201D\u201E]/.test(meta_description)) { - message = - "Found Microsoft smart double quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } else if (/[\u2018\u2019\u201A]/.test(meta_description)) { - message = - "Found Microsoft Smart single quotes and apostrophe. These special characters are not allowed in meta descriptions."; - } - - setError(message); - } - }, [meta_description]); - - if (error) { - dispatch( - notify({ - kind: "warn", - message: error, - }) - ); - } - - return ( -
    - - onChange(evt.target.value, "metaDescription")} - multiline - rows={6} - /> - -
    - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less deleted file mode 100644 index 4e94f5b229..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaDescription.less +++ /dev/null @@ -1,13 +0,0 @@ -@import "~@zesty-io/core/colors.less"; - -.MetaDescription { - display: flex; - flex-direction: column; - label { - display: flex; - } - .error { - background-color: @zesty-highlight; - padding: 8px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less deleted file mode 100644 index 5e8029ecc2..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaKeywords { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js deleted file mode 100644 index 07fe483b7d..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.js +++ /dev/null @@ -1,37 +0,0 @@ -import { memo } from "react"; - -import { TextField } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; - -import styles from "./MetaLinkText.less"; -export const MetaLinkText = memo(function MetaLinkText({ - meta_link_text, - onChange, - errors, -}) { - return ( -
    - - onChange(evt.target.value, "metaLinkText")} - /> - -
    - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less deleted file mode 100644 index ddbb8b4eda..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaLinkText.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaLinkText { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js deleted file mode 100644 index 65dd215ff8..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.js +++ /dev/null @@ -1,35 +0,0 @@ -import { memo } from "react"; - -import { TextField } from "@mui/material"; - -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; -import styles from "./MetaTitle.less"; -export const MetaTitle = memo(function MetaTitle({ - meta_title, - onChange, - errors, -}) { - return ( -
    - - onChange(evt.target.value, "metaTitle")} - /> - -
    - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less deleted file mode 100644 index 7ed0d77cec..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaTitle.less +++ /dev/null @@ -1,7 +0,0 @@ -.MetaTitle { - display: flex; - flex-direction: column; - label { - display: flex; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js deleted file mode 100644 index 1f0f13f8e4..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.js +++ /dev/null @@ -1,43 +0,0 @@ -import { memo } from "react"; - -import { Select, MenuItem } from "@mui/material"; - -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import styles from "./SitemapPriority.less"; -export const SitemapPriority = memo(function SitemapPriority(props) { - return ( -
    - - - -
    - ); -}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js deleted file mode 100644 index 34f572cf6e..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.js +++ /dev/null @@ -1,26 +0,0 @@ -import { Header } from "../components/Header"; - -import { ItemSettings } from "./ItemSettings"; -import { DataSettings } from "./ItemSettings/DataSettings"; - -import styles from "./Meta.less"; -export function Meta(props) { - return ( -
    -
    - {props.model && props.model?.type === "dataset" ? ( - - ) : ( - - )} -
    -
    - ); -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less deleted file mode 100644 index f73ba1b173..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/Meta.less +++ /dev/null @@ -1,10 +0,0 @@ -.MetaEdit { - display: flex; - flex: 1; - background-color: #f9fafb; - overflow: scroll; - flex-direction: column; - .MetaWrap { - margin: 4px 32px 20px 32px; - } -} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx new file mode 100644 index 0000000000..237399ecf0 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/FacebookPreview.tsx @@ -0,0 +1,79 @@ +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; + +type FacebookPreviewProps = { + imageURL: string; +}; +export const FacebookPreview = ({ imageURL }: FacebookPreviewProps) => { + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + alignSelf: "center", + }} + height={290} + width="100%" + src={`${imageURL}?width=500&height=290&fit=cover`} + flexShrink={0} + /> + ) : ( + + + + )} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + {item?.data?.og_title || item?.web?.metaTitle || "Meta Title"} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx new file mode 100644 index 0000000000..0999c1b62f --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/GooglePreview.tsx @@ -0,0 +1,171 @@ +import { Fragment, useMemo, useEffect } from "react"; +import { Box, Stack, Typography, Breadcrumbs } from "@mui/material"; +import { + MoreVertRounded, + ArrowForwardIosRounded, + ImageRounded, +} from "@mui/icons-material"; +import { useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + +import { useGetInstanceQuery } from "../../../../../../../../shell/services/accounts"; +import { InstanceAvatar } from "../../../../../../../../shell/components/global-sidebar/components/InstanceAvatar"; +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; + +type GooglePreviewProps = { + imageURL: string; +}; +export const GooglePreview = ({ imageURL }: GooglePreviewProps) => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: instance, isLoading: isLoadingInstance } = + useGetInstanceQuery(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const parent = items[item?.web?.parentZUID]; + + const fullPathArray = useMemo(() => { + let path: string[] = [domain]; + + if (parent) { + path = [...path, ...(parent.web?.path?.split("/") || [])]; + } + + // Remove empty strings + return path.filter((i) => !!i); + }, [domain, parent, item?.web]); + + return ( + + + + + + + {instance?.name} + + + + {fullPathArray.map((path, index) => ( + + {path} + {index < fullPathArray?.length - 1 && ( + + › + + )} + + ))} + + theme.palette.text.secondary, + }} + /> + + + + + {item?.web?.metaTitle || "Meta Title"} + + + {item?.web?.metaDescription || "Meta Description"} + + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + }} + width={82} + height={82} + src={`${imageURL}?width=82&height=82&fit=cover`} + flexShrink={0} + borderRadius={2} + /> + ) : ( + + + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx new file mode 100644 index 0000000000..7fb999780c --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/LinkedInPreview.tsx @@ -0,0 +1,92 @@ +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; + +type LinkedInPreviewProps = { + imageURL: string; +}; +export const LinkedInPreview = ({ imageURL }: LinkedInPreviewProps) => { + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + alignSelf: "center", + borderRadius: 2, + }} + height={72} + width={128} + src={`${imageURL}?width=128&height=72&fit=cover`} + flexShrink={0} + /> + ) : ( + + + + )} + + + {item?.data?.og_title || item?.web?.metaTitle || "Meta Title"} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx new file mode 100644 index 0000000000..ac34cac0e8 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/TwitterPreview.tsx @@ -0,0 +1,122 @@ +import { useEffect } from "react"; +import { Typography, Box, Stack } from "@mui/material"; +import { ImageRounded } from "@mui/icons-material"; +import { useLocation, useParams } from "react-router"; +import { useSelector } from "react-redux"; + +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { AppState } from "../../../../../../../../shell/store/types"; + +type TwitterPreviewProps = { + imageURL: string; +}; +export const TwitterPreview = ({ imageURL }: TwitterPreviewProps) => { + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + + return ( + + {!!imageURL ? ( + theme.palette.grey[100], + objectFit: "cover", + }} + width={128} + height={128} + src={`${imageURL}?width=128&height=128&fit=cover`} + flexShrink={0} + borderRadius="8px 0 0 8px" + /> + ) : ( + + + + )} + + + {domain.replace(/http:\/\/|https:\/\//gm, "")} + + + {item?.data?.tc_title || item?.web?.metaTitle || "Meta Title"} + + + {item?.data?.tc_description || + item?.web?.metaDescription || + "Meta Description"} + + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx new file mode 100644 index 0000000000..4ee52f0827 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/index.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { Tab, Tabs, Box } from "@mui/material"; +import { + Google, + Twitter, + FacebookRounded, + LinkedIn, +} from "@mui/icons-material"; +import { GooglePreview } from "./GooglePreview"; +import { TwitterPreview } from "./TwitterPreview"; +import { FacebookPreview } from "./FacebookPreview"; +import { LinkedInPreview } from "./LinkedInPreview"; +import { useImageURL } from "./useImageURL"; + +enum SocialMediaTab { + Google, + Twitter, + Facebook, + LinkedIn, +} +type SocialMediaPreviewProps = {}; +export const SocialMediaPreview = ({}: SocialMediaPreviewProps) => { + const imageURL = useImageURL(); + const [activeTab, setActiveTab] = useState( + SocialMediaTab.Google + ); + + return ( + <> + `2px solid ${theme?.palette?.border} `, + }} + > + setActiveTab(value)} + sx={{ + position: "relative", + top: "2px", + }} + > + } + iconPosition="start" + label="Google" + value={SocialMediaTab.Google} + /> + } + iconPosition="start" + label="Twitter (X)" + value={SocialMediaTab.Twitter} + /> + } + iconPosition="start" + label="Facebook" + value={SocialMediaTab.Facebook} + /> + } + iconPosition="start" + label="LinkedIn" + value={SocialMediaTab.LinkedIn} + /> + + + {activeTab === SocialMediaTab.Google && ( + + )} + {activeTab === SocialMediaTab.Twitter && ( + + )} + {activeTab === SocialMediaTab.Facebook && ( + + )} + {activeTab === SocialMediaTab.LinkedIn && ( + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts new file mode 100644 index 0000000000..72213c3193 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/SocialMediaPreview/useImageURL.ts @@ -0,0 +1,97 @@ +import { useEffect, useMemo, useState } from "react"; +import { useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + +import { useGetContentModelFieldsQuery } from "../../../../../../../../shell/services/instance"; +import { useLazyGetFileQuery } from "../../../../../../../../shell/services/mediaManager"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { fileExtension } from "../../../../../../../media/src/app/utils/fileUtils"; + +export const useImageURL: () => string = () => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID); + const [getFile] = useLazyGetFileQuery(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const [imageURL, setImageURL] = useState(null); + + const contentImages = useMemo(() => { + if (!modelFields?.length || !Object.keys(item?.data ?? {})?.length) return; + + const mediaFieldsWithImageOnTheName: string[] = []; + const otherMediaFields: string[] = []; + + modelFields.forEach((field) => { + if ( + !field.deletedAt && + field.datatype === "images" && + field?.name !== "og_image" && + !!item?.data?.[field.name] + ) { + if ( + field.label.toLowerCase().includes("image") || + field.name.toLocaleLowerCase().includes("image") + ) { + mediaFieldsWithImageOnTheName.push( + ...String(item.data[field.name]).split(",") + ); + } else { + otherMediaFields.push(...String(item.data[field.name]).split(",")); + } + } + }); + + return [...mediaFieldsWithImageOnTheName, ...otherMediaFields]; + }, [modelFields, item?.data]); + + useEffect(() => { + if (!!item?.data?.og_image) { + if (String(item.data.og_image).startsWith("3-")) { + getFile(String(item.data.og_image)) + .unwrap() + .then((res) => { + setImageURL(res.url); + }); + } else { + setImageURL(String(item.data.og_image)); + } + } else { + if (!contentImages?.length) { + setImageURL(null); + return; + } + + let validImages = contentImages.map(async (value) => { + const isZestyMediaFile = value.startsWith("3-"); + // Need to resolve media zuids to determine if these are actually images + const res = isZestyMediaFile && (await getFile(value).unwrap()); + const isImage = [ + "png", + "jpg", + "jpeg", + "svg", + "gif", + "tif", + "webp", + ].includes(fileExtension(isZestyMediaFile ? res.url : value)); + + if (isImage) { + return isZestyMediaFile ? res.url : value; + } + }); + + Promise.all(validImages).then((data) => { + setImageURL(data?.[0]); + }); + } + }, [JSON.stringify(contentImages), item?.data?.og_image]); + + return imageURL; +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js deleted file mode 100644 index f75005249b..0000000000 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.js +++ /dev/null @@ -1 +0,0 @@ -export { Meta } from "./Meta"; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx new file mode 100644 index 0000000000..38525faa07 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/index.tsx @@ -0,0 +1,590 @@ +import { + useState, + useCallback, + useMemo, + useEffect, + forwardRef, + useImperativeHandle, + useRef, +} from "react"; +import { + Stack, + Box, + Typography, + ThemeProvider, + Divider, + ListItemIcon, + ListItemText, + ListItemButton, +} from "@mui/material"; +import { Brain, theme } from "@zesty-io/material"; +import { useParams, useLocation } from "react-router"; +import { useSelector, useDispatch } from "react-redux"; +import { keyframes } from "@mui/system"; +import { EditRounded } from "@mui/icons-material"; +import { cloneDeep } from "lodash"; + +import { ContentInsights } from "./ContentInsights"; +import { + useGetContentModelQuery, + useGetContentModelFieldsQuery, +} from "../../../../../../../shell/services/instance"; +import { AppState } from "../../../../../../../shell/store/types"; +import { Error } from "../../../components/Editor/Field/FieldShell"; +import { fetchGlobalItem } from "../../../../../../../shell/store/content"; +import { + ContentModelField, + Web, +} from "../../../../../../../shell/services/types"; +import { SocialMediaPreview } from "./SocialMediaPreview"; +import { validateMetaDescription } from "./settings/util"; + +// Fields +import { MetaImage } from "./settings/MetaImage"; +import { CanonicalTag } from "./settings/CanonicalTag"; +import { ItemParent } from "./settings/ItemParent"; +import { ItemRoute } from "./settings/ItemRoute"; +import MetaDescription from "./settings/MetaDescription"; +import { MetaKeywords } from "./settings/MetaKeywords"; +import { MetaLinkText } from "./settings/MetaLinkText"; +import { MetaTitle } from "./settings/MetaTitle"; +import { SitemapPriority } from "./settings/SitemapPriority"; +import { OGTitle } from "./settings/OGTitle"; +import { OGDescription } from "./settings/OGDescription"; +import { TCTitle } from "./settings/TCTitle"; +import { TCDescription } from "./settings/TCDescription"; +import { FieldError } from "../../../components/Editor/FieldError"; + +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; +const FlowType = { + AIGenerated: "ai-generated", + Manual: "manual", +} as const; +const flowButtons = [ + { + flowType: FlowType.AIGenerated, + icon: , + primaryText: "Yes, improve with AI Meta Data Assistant", + secondaryText: + "Our AI will scan your content and generate your meta data for you", + }, + { + flowType: FlowType.Manual, + icon: , + primaryText: "No, I will improve and edit it myself", + secondaryText: + "Perfect if you already know what you want your Meta Data to be", + }, +]; +export const MaxLengths: Record = { + metaLinkText: 150, + metaTitle: 150, + metaDescription: 160, + metaKeywords: 255, + og_title: 150, + og_description: 160, + tc_title: 150, + tc_description: 160, +}; +export const DYNAMIC_META_FIELD_NAMES = [ + "og_title", + "og_description", + "tc_title", + "tc_description", +]; + +type Errors = Record; +type MetaProps = { + isSaving: boolean; + onUpdateSEOErrors: (errors: Errors) => void; + errors: Errors; + errorComponent?: React.ReactNode; +}; +export const Meta = forwardRef( + ({ isSaving, onUpdateSEOErrors, errors, errorComponent }: MetaProps, ref) => { + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const { data: model } = useGetContentModelQuery(modelZUID, { + skip: !modelZUID, + }); + const { data: fields } = useGetContentModelFieldsQuery(modelZUID); + const { meta, data, web } = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const [flowType, setFlowType] = + useState(null); + const metaDescriptionButtonRef = useRef(null); + const metaTitleButtonRef = useRef(null); + + // @ts-expect-error untyped + const siteName = useMemo(() => dispatch(fetchGlobalItem())?.site_name, []); + + const metaFields = useMemo(() => { + if (fields.length) { + return fields.reduce( + ( + accu: Record, + curr: ContentModelField + ) => { + if ( + !curr.deletedAt && + DYNAMIC_META_FIELD_NAMES.includes(curr.name.toLowerCase()) + ) { + accu[curr.name] = curr; + } + + return accu; + }, + {} + ); + } + + return {}; + }, [fields]); + + const REQUIRED_FIELDS = useMemo(() => { + const fields = ["metaTitle", "parentZUID", "pathPart"]; + + if (model?.type !== "dataset") { + fields.push("metaDescription"); + } + + return fields; + }, [model]); + + const handleOnChange = useCallback( + (value, name) => { + if (!name) { + throw new Error("Input is missing name attribute"); + } + + const currentErrors = cloneDeep(errors); + + if (REQUIRED_FIELDS.includes(name)) { + currentErrors[name] = { + ...currentErrors?.[name], + MISSING_REQUIRED: !value, + }; + } + + if (MaxLengths[name]) { + currentErrors[name] = { + ...currentErrors?.[name], + EXCEEDING_MAXLENGTH: + value?.length > MaxLengths[name] + ? value?.length - MaxLengths[name] + : 0, + }; + } + + if (DYNAMIC_META_FIELD_NAMES.includes(name) && name in metaFields) { + const isRequired = metaFields[name].required; + + currentErrors[name] = { + ...currentErrors[name], + MISSING_REQUIRED: isRequired ? !value : false, + }; + } + + if (name === "metaDescription") { + const metaDescriptionError = validateMetaDescription(value); + + currentErrors.metaDescription = { + ...currentErrors.metaDescription, + CUSTOM_ERROR: !!metaDescriptionError ? metaDescriptionError : "", + }; + } + + onUpdateSEOErrors(currentErrors); + + dispatch({ + // The og_image is stored as an ordinary field item and not a SEO field item + type: [...DYNAMIC_META_FIELD_NAMES, "og_image"].includes(name) + ? "SET_ITEM_DATA" + : "SET_ITEM_WEB", + itemZUID: meta?.ZUID, + key: name, + value: value, + }); + }, + [meta?.ZUID, errors] + ); + + useImperativeHandle( + ref, + () => { + return { + validateMetaFields() { + const currentErrors = cloneDeep(errors); + + REQUIRED_FIELDS.forEach((fieldName) => { + // @ts-expect-error + const value = web[fieldName]; + + currentErrors[fieldName] = { + ...currentErrors?.[fieldName], + MISSING_REQUIRED: !value, + }; + }); + + Object.keys(MaxLengths).forEach((fieldName) => { + const value = DYNAMIC_META_FIELD_NAMES.includes(fieldName) + ? data[fieldName] + : // @ts-expect-error + web[fieldName]; + + currentErrors[fieldName] = { + ...currentErrors?.[fieldName], + EXCEEDING_MAXLENGTH: + value?.length > MaxLengths[fieldName] + ? value?.length - MaxLengths[fieldName] + : 0, + }; + }); + + Object.entries(metaFields).forEach(([name, settings]) => { + const isRequired = settings.required; + const value = data[name] as string; + + currentErrors[name] = { + ...currentErrors?.[name], + MISSING_REQUIRED: isRequired ? !value : false, + }; + }); + + // Validate meta description value + const metaDescriptionError = validateMetaDescription( + web.metaDescription || "" + ); + + currentErrors.metaDescription = { + ...currentErrors.metaDescription, + CUSTOM_ERROR: !!metaDescriptionError ? metaDescriptionError : "", + }; + + // No need to validate pathPart for datasets + if (model?.type === "dataset" || web?.pathPart === "zesty_home") { + delete currentErrors.pathPart; + delete currentErrors.parentZUID; + } + + setTimeout(() => { + // Makes sure that the user sees the error blurbs on each + // field when in the create item page + if (isCreateItemPage) { + setFlowType(FlowType.Manual); + } + onUpdateSEOErrors(currentErrors); + }); + + return Object.values(currentErrors) + ?.map((error) => { + return Object.values(error) ?? []; + }) + ?.flat() + .some((error) => !!error); + }, + triggerAIGeneratedFlow() { + setFlowType(FlowType.AIGenerated); + }, + }; + }, + [errors, web, model, metaFields, data] + ); + + useEffect(() => { + if (isSaving) { + onUpdateSEOErrors({}); + return; + } + }, [isSaving]); + + useEffect(() => { + if (!isCreateItemPage) return; + + if (flowType === FlowType.AIGenerated) { + metaTitleButtonRef.current?.triggerAIButton?.(); + } + }, [flowType, isCreateItemPage]); + + if (isCreateItemPage && flowType === null) { + return ( + + + + + + Would you like to improve your Meta Title & Description? + + + Our AI Assistant will scan your content and improve your meta + title and description to help improve search engine + visibility.{" "} + + + {flowButtons.map((data) => ( + setFlowType(data.flowType)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + py: 2, + }} + > + {data.icon} + + {data.primaryText} + + } + disableTypography + sx={{ my: 0 }} + secondary={ + + {data.secondaryText} + + } + /> + + ))} + + + + ); + } + + return ( + + + + + {!!errorComponent && errorComponent} + + + SEO & Open Graph Settings + + + Specify this page's title and description. You can see how + they'll look in search engine results pages (SERPs) and social + media content in the preview on the right. + + + { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta title"); + setFlowType(FlowType.Manual); + } + }} + onAIMetaTitleInserted={() => { + // Scroll to and open the meta description ai generator to continue + // with the AI-assisted flow + if (flowType === FlowType.AIGenerated) { + metaDescriptionButtonRef.current?.triggerAIButton?.(); + } + }} + /> + { + if (flowType === FlowType.AIGenerated) { + console.log("reset on meta description"); + setFlowType(FlowType.Manual); + } + }} + isAIAssistedFlow={flowType === FlowType.AIGenerated} + required={REQUIRED_FIELDS.includes("metaDescription")} + /> + + {"og_title" in metaFields && ( + + )} + {"og_description" in metaFields && ( + + )} + {"tc_title" in metaFields && ( + + )} + {"tc_description" in metaFields && ( + + )} + + {model?.type !== "dataset" && web?.pathPart !== "zesty_home" && ( + + + + URL Settings + + + Define the URL of your web page + + + + { + onUpdateSEOErrors({ + ...errors, + [name]: { + ...errors?.[name], + ...error, + }, + }); + }} + /> + + )} + + + + Advanced Settings + + + Optimize your content item's SEO further + + + {model?.type !== "dataset" && ( + <> + + {!!web && ( + + )} + + )} + + + + + {!isCreateItemPage && ( + + + {model?.type !== "dataset" && ( + <> + + + + )} + + + + )} + + + ); + } +); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js similarity index 80% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js index b6f5f4982e..115c59ebe0 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.js @@ -1,25 +1,25 @@ import { memo, useState } from "react"; -import { TextField, Select, MenuItem } from "@mui/material"; +import { TextField, Select, MenuItem, Autocomplete } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; const CANONICAL_OPTS = [ { value: 0, - text: "Off", + label: "Off", }, { value: 1, - text: "On (Ignores query parameters)", + label: "On (Ignores query parameters)", }, { value: 2, - text: "On - Allow certain parameters", + label: "On - Allow certain parameters", }, { value: 3, - text: "On - Custom Path or Custom URL", + label: "On - Custom Path or Custom URL", }, ]; @@ -52,7 +52,7 @@ export const CanonicalTag = memo(function CanonicalTag(props) { settings={{ label: "Canonical Tag", }} - customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto creates tags on demand based on your settings." + customTooltip="Canonical tags help search engines understand authoritative links and can help prevent duplicate content issues. Zesty.io auto-creates tags on demand based on your settings." withInteractiveTooltip={false} > {zestyStore.getState().instance.settings.seo[ @@ -69,21 +69,15 @@ export const CanonicalTag = memo(function CanonicalTag(props) { ) : (
    - + renderInput={(params) => } + onChange={(_, value) => { + handleMode(value ? value.value : 1, "canonicalTagMode"); + }} + /> {mode == "2" ? (
    diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less similarity index 100% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/CanonicalTag.less rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/CanonicalTag.less diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx new file mode 100644 index 0000000000..a9cb8bd91b --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemParent.tsx @@ -0,0 +1,262 @@ +import { Box, Autocomplete, TextField, ListItem } from "@mui/material"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { useDispatch, useSelector } from "react-redux"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { + ContentItemWithDirtyAndPublishing, + ContentNavItem, +} from "../../../../../../../../shell/services/types"; +import { debounce, uniqBy } from "lodash"; +import { useEffect, useState } from "react"; +import { useLocation, useParams } from "react-router"; +import { notify } from "../../../../../../../../shell/store/notifications"; +import { searchItems } from "../../../../../../../../shell/store/content"; +import { useGetContentNavItemsQuery } from "../../../../../../../../shell/services/instance"; + +type ParentOption = { + value: string; + text: string; +}; +const getParentOptions = ( + currentItemLangID: number, + path: string, + items: Record +) => { + const options: ParentOption[] = Object.entries(items) + ?.reduce((acc, [itemZUID, itemData]) => { + if ( + itemZUID.slice(0, 3) !== "new" && // Exclude new items + itemData?.meta?.ZUID && // must have a ZUID + itemData?.web?.path && // must have a path + itemData?.web.path !== "/" && // Exclude homepage + itemData?.web.path !== path && // Exclude current item + itemData?.meta?.langID === currentItemLangID // display only relevant language options + ) { + acc.push({ + value: itemZUID, + text: itemData.web.path, + }); + } + + return acc; + }, []) + .sort((a, b) => { + if (a.text > b.text) { + return 1; + } else if (a.text < b.text) { + return -1; + } else { + return 0; + } + }); + + // Insert the home route + options.unshift({ + text: "/", + value: "0", // 0 = root level + }); + + return uniqBy(options, "value"); +}; + +const findNavParent = (zuid: string, nav: ContentNavItem[], count = 0): any => { + count++; + const navEntry = nav?.find((el: any) => el.ZUID === zuid); + if (navEntry) { + // This first item should be the model we are resolving for so + // continue on up the nav tree + if (count === 0) { + return findNavParent(navEntry.parentZUID, nav, count); + } else { + if (navEntry.type === "item") { + return navEntry.ZUID; + } else if (navEntry.parentZUID) { + return findNavParent(navEntry.parentZUID, nav, count); + } else { + return "0"; + } + } + } else { + return "0"; + } +}; + +type ItemParentProps = { + onChange: (value: string, name: string) => void; +}; +export const ItemParent = ({ onChange }: ItemParentProps) => { + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const { data: rawNavData } = useGetContentNavItemsQuery(); + const [selectedParent, setSelectedParent] = useState({ + value: "0", // "0" = root level route + text: "/", + }); + const [options, setOptions] = useState( + getParentOptions(item?.meta?.langID, item?.web?.path, items) + ); + const [isLoadingOptions, setIsLoadingOptions] = useState(false); + + const handleSearchOptions = debounce((filterTerm) => { + if (filterTerm) { + dispatch(searchItems(filterTerm)) + // @ts-expect-error untyped + .then((res) => { + setOptions( + getParentOptions(item?.meta?.langID, item?.web?.path, { + ...items, + // needs to reduce and converts this data as the same format of the items to + // prevent having an issue on having an itemZUID with an incorrect format + // the reason is that the item has a format of {[itemZUID]:data} + // while the res.data has a value of an array which cause the needs of converting + // the response to an object with a zuid as a key + ...res?.data.reduce( + ( + acc: Record, + curr: ContentItemWithDirtyAndPublishing + ) => { + return { ...acc, [curr.meta.ZUID]: curr }; + }, + {} + ), + }) + ); + }) + .finally(() => setIsLoadingOptions(false)); + } + }, 1000); + + useEffect(() => { + let { parentZUID } = item?.web; + const { ZUID: itemZUID } = item?.meta; + + // If it's a new item chase down the parentZUID within navigation + // This way we avoid an API request + if (itemZUID && itemZUID.slice(0, 3) === "new") { + const result = findNavParent(modelZUID, rawNavData); + + // change for preselection + parentZUID = result; + + // Update redux store so if the item is saved we know it's parent + onChange(parentZUID, "parentZUID"); + } + + // Try to preselect parent + if (parentZUID && parentZUID !== "0") { + const parentItem = items[parentZUID]; + if (parentItem?.meta?.ZUID && parentItem?.web?.path) { + setSelectedParent({ + value: parentItem.meta.ZUID, + text: parentItem.web.path, + }); + } else { + dispatch(searchItems(parentZUID)) + // @ts-expect-error untyped + .then((res) => { + if (res?.data) { + if (Array.isArray(res.data) && res.data.length) { + // Handles cases where the model's parent is the homepage. This is no longer possible for newly created models but + // there are some old models that still have the homepage as their parent models. + if (res.data[0]?.web?.path === "/") { + setSelectedParent({ + value: "0", // "0" = root level route + text: "/", + }); + } else { + setSelectedParent({ + value: res.data?.[0]?.meta?.ZUID, + text: res.data?.[0]?.web?.path, + }); + /** + * // HACK Because we pre-load all item publishings and store them in the same reducer as the `content` + * we can't use array length comparision to determine a new parent has been added. Also since updates to the item + * currently being edited cause a new `content` object to be created in it's reducer we can't use + * referential equality checks to determine re-rendering. This scenario causes either the parent to not be pre-selected + * or a performance issue. To work around this we maintain the `parents` state internal and add the new parent we load from the + * API to allow it to be pre-selected while avoiding re-renders on changes to this item. + */ + + setOptions( + getParentOptions(item?.meta?.langID, item?.web?.path, { + ...items, + [res.data[0].meta.ZUID]: res.data[0], + }) + ); + } + } else { + dispatch( + notify({ + kind: "warn", + heading: `Cannot Save: ${item?.web?.metaTitle}`, + message: `Page's Parent does not exist or has been deleted`, + }) + ); + } + } else { + dispatch( + notify({ + kind: "warn", + message: `API failed to return data. Try Again.`, + }) + ); + } + }); + } + } + }, []); + + return ( + + + } + renderOption={(props, value) => ( + + {value.text} + + )} + getOptionLabel={(option) => option.text} + onInputChange={(_, filterTerm) => { + if (filterTerm !== "/") { + setIsLoadingOptions(!!filterTerm); + handleSearchOptions(filterTerm); + } + }} + onChange={(_, value) => { + // Always default to homepage when no parent is selected + setSelectedParent( + value !== null ? value : { text: "/", value: "0" } + ); + onChange(value !== null ? value.value : "0", "parentZUID"); + }} + loading={isLoadingOptions} + sx={{ + "& .MuiOutlinedInput-root": { + padding: "2px", + }, + }} + /> + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx new file mode 100644 index 0000000000..806649a69c --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/ItemRoute.tsx @@ -0,0 +1,201 @@ +import { ChangeEvent, useCallback, useEffect, useState } from "react"; +import { + TextField, + InputAdornment, + CircularProgress, + Typography, + Box, +} from "@mui/material"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; +import { debounce } from "lodash"; +import { CheckCircleRounded, CancelRounded } from "@mui/icons-material"; + +import { withCursorPosition } from "../../../../../../../../shell/components/withCursorPosition"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { searchItems } from "../../../../../../../../shell/store/content"; +import { notify } from "../../../../../../../../shell/store/notifications"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { ContentItemWithDirtyAndPublishing } from "../../../../../../../../shell/services/types"; +import { useDomain } from "../../../../../../../../shell/hooks/use-domain"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; + +const TextFieldWithCursorPosition = withCursorPosition(TextField); + +type ItemRouteProps = { + onChange: (value: string, name: string) => void; + error: Error; + onUpdateErrors: (name: string, error: Error) => void; +}; +export const ItemRoute = ({ + onChange, + error, + onUpdateErrors, +}: ItemRouteProps) => { + const dispatch = useDispatch(); + const { itemZUID, modelZUID } = useParams<{ + itemZUID: string; + modelZUID: string; + }>(); + const domain = useDomain(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const items = useSelector((state: AppState) => state.content); + const item = items[isCreateItemPage ? `new:${modelZUID}` : itemZUID]; + const [pathPart, setPathPart] = useState(item?.web?.pathPart); + const [isLoading, setIsLoading] = useState(false); + const [isUnique, setIsUnique] = useState(true); + + const parent = items[item?.web?.parentZUID]; + + const validate = useCallback( + debounce((path) => { + if (!path) { + setIsUnique(false); + return; + } + + const fullPath = parent ? `${parent.web?.path}${path}/` : `/${path}/`; + + setIsLoading(true); + + return ( + dispatch(searchItems(fullPath)) + // @ts-expect-error untyped + .then((res) => { + if (res?.data) { + if (Array.isArray(res.data) && res.data.length) { + // check list of partial matches for exact path match + const matches = res.data.filter( + (_item: ContentItemWithDirtyAndPublishing) => { + /** + * Exclude currently viewed item zuid, as it's currently saved path would match. + * Check if other results have a matching path, if so then it is already taken and + * can not be used. + * Result paths come with leading and trailing slashes + */ + return ( + _item.meta.ZUID !== item?.meta?.ZUID && + _item.web.path === fullPath + ); + } + ); + + setIsUnique(!matches.length); + onUpdateErrors("pathPart", { + CUSTOM_ERROR: !!matches.length + ? "This URL Path Part is already taken. Please enter a new different URL Path part." + : "", + }); + } else { + setIsUnique(true); + onUpdateErrors("pathPart", { + CUSTOM_ERROR: "", + }); + } + } else { + dispatch( + notify({ + kind: "warn", + message: `API failed to return data ${res.status}`, + }) + ); + } + }) + .finally(() => setIsLoading(false)) + ); + }, 1000), + [parent] + ); + + const handleInputChange = (evt: ChangeEvent) => { + // All URLs are lowercased + // Replace ampersand characters with 'and' + // Only allow alphanumeric characters + const path = evt.target.value + .toLowerCase() + .replace(/\&/g, "and") + .replace(/[^a-zA-Z0-9]/g, "-"); + + validate(path); + setPathPart(path); + + onChange(path, "pathPart"); + }; + + // Revalidate when parent path changes + useEffect(() => { + validate(pathPart); + }, [parent?.web, pathPart]); + + useEffect(() => { + setPathPart(item?.web?.pathPart || ""); + }, [item?.web]); + + return ( + + + + ), + }} + helperText={ + !!pathPart && + isUnique && ( + + {domain} + {parent ? parent.web?.path + pathPart : `/${pathPart}`} + + ) + } + error={hasErrors(error)} + /> + + + ); +}; + +type AdornmentProps = { + isLoading: boolean; + isUnique: boolean; +}; +const Adornment = ({ isLoading, isUnique }: AdornmentProps) => { + if (isLoading) { + return ( + + + + ); + } + + return ( + + {isUnique ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx new file mode 100644 index 0000000000..8e9668f751 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaDescription.tsx @@ -0,0 +1,72 @@ +import { useState, useEffect, ChangeEvent } from "react"; +import { connect, useDispatch } from "react-redux"; +import { TextField, Box } from "@mui/material"; + +import { notify } from "../../../../../../../../shell/store/notifications"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { withAI } from "../../../../../../../../shell/components/withAi"; +import { MutableRefObject } from "react"; + +const AIFieldShell = withAI(FieldShell); + +type MetaDescriptionProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + onResetFlowType: () => void; + aiButtonRef?: MutableRefObject; + isAIAssistedFlow: boolean; + required: boolean; +}; +export default connect()(function MetaDescription({ + value, + onChange, + error, + onResetFlowType, + aiButtonRef, + isAIAssistedFlow, + required, +}: MetaDescriptionProps) { + return ( + + ) => { + onChange(evt.target.value, "metaDescription"); + onResetFlowType?.(); + }} + onResetFlowType={() => { + onResetFlowType?.(); + }} + isAIAssistedFlow={isAIAssistedFlow} + > + onChange(evt.target.value, "metaDescription")} + multiline + rows={3} + error={hasErrors(error)} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx new file mode 100644 index 0000000000..40f5ee4817 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaImage.tsx @@ -0,0 +1,334 @@ +import { useState, useEffect, useMemo, useRef } from "react"; +import { Dialog, IconButton, Stack } from "@mui/material"; +import { LoadingButton } from "@mui/lab"; +import { AddRounded, Close, EditRounded } from "@mui/icons-material"; +import { MemoryRouter, useLocation, useParams } from "react-router"; +import { useDispatch, useSelector } from "react-redux"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { AppState } from "../../../../../../../../shell/store/types"; +import { + useCreateContentModelFieldMutation, + useGetContentModelFieldsQuery, + useUndeleteContentModelFieldMutation, +} from "../../../../../../../../shell/services/instance"; +import { + FieldTypeMedia, + MediaItem, +} from "../../../../components/FieldTypeMedia"; +import { MediaApp } from "../../../../../../../media/src/app"; +import { useLazyGetFileQuery } from "../../../../../../../../shell/services/mediaManager"; +import { fileExtension } from "../../../../../../../media/src/app/utils/fileUtils"; +import { fetchFields } from "../../../../../../../../shell/store/fields"; + +type MetaImageProps = { + onChange: (value: string, name: string) => void; +}; +export const MetaImage = ({ onChange }: MetaImageProps) => { + const dispatch = useDispatch(); + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const fieldTypeMedia = useRef(null); + const { data: modelFields } = useGetContentModelFieldsQuery(modelZUID); + const [ + createContentModelField, + { + isLoading: isCreatingOgImageField, + isSuccess: isOgImageFieldCreated, + error: ogImageFieldCreationError, + }, + ] = useCreateContentModelFieldMutation(); + const [ + undeleteContentModelField, + { isLoading: isUndeletingField, isSuccess: isFieldUndeleted }, + ] = useUndeleteContentModelFieldMutation(); + const [getFile] = useLazyGetFileQuery(); + const [imageModal, setImageModal] = useState(null); + const [autoOpenMediaBrowser, setAutoOpenMediaBrowser] = useState(false); + const [temporaryMetaImageURL, setTemporaryMetaImageURL] = + useState(null); + const [showOGImageField, setShowOGImageField] = useState(false); + + const isBynderSessionValid = + localStorage.getItem("cvrt") && localStorage.getItem("cvad"); + + const contentImages = useMemo(() => { + if (!modelFields?.length || !Object.keys(item?.data ?? {})?.length) return; + const mediaFieldsWithImageOnTheName: string[] = []; + const otherMediaFields: string[] = []; + + modelFields.forEach((field) => { + if ( + !field.deletedAt && + field.datatype === "images" && + field?.name !== "og_image" && + !!item?.data?.[field.name] + ) { + if ( + field.label.toLowerCase().includes("image") || + field.name.toLocaleLowerCase().includes("image") + ) { + mediaFieldsWithImageOnTheName.push( + ...String(item.data[field.name]).split(",") + ); + } else { + otherMediaFields.push(...String(item.data[field.name]).split(",")); + } + } + }); + + return [...mediaFieldsWithImageOnTheName, ...otherMediaFields]; + }, [modelFields, item?.data]); + + useEffect(() => { + if (!contentImages?.length) { + setTemporaryMetaImageURL(null); + return; + } + + let validImages = contentImages.map(async (value) => { + const isZestyMediaFile = value.startsWith("3-"); + // Need to resolve media zuids to determine if these are actually images + const res = isZestyMediaFile && (await getFile(value).unwrap()); + const isImage = [ + "png", + "jpg", + "jpeg", + "svg", + "gif", + "tif", + "webp", + ].includes(fileExtension(isZestyMediaFile ? res.url : value)); + + if (isImage) { + return value; + } + }); + + Promise.all(validImages).then((data) => { + setTemporaryMetaImageURL(data?.[0]); + }); + }, [JSON.stringify(contentImages), temporaryMetaImageURL]); + + const handleCreateOgImageField = () => { + const existingOgImageField = modelFields?.find( + (field) => field.name === "og_image" + ); + + if (!!existingOgImageField && !!existingOgImageField.deletedAt) { + // If the og_image field already exists in the model but was deactivated, reactivate it + undeleteContentModelField({ + modelZUID, + fieldZUID: existingOgImageField.ZUID, + }); + } else { + // If the model has no og_image field yet, create it + createContentModelField({ + modelZUID, + body: { + contentModelZUID: modelZUID, + datatype: "images", + description: + "This field allows you to set an open graph image via the SEO tab. An Open Graph (OG) image is an image that appears on a social media post when a web page is shared.", + label: "Meta Image", + name: "og_image", + required: false, + settings: { + defaultValue: null, + group_id: "", + limit: 1, + list: false, + }, + sort: modelFields?.length, // Adds it to the end of the current model's field list + }, + }); + } + + setShowOGImageField(true); + }; + + useEffect(() => { + if ( + (!isCreatingOgImageField && isOgImageFieldCreated) || + (!isUndeletingField && isFieldUndeleted) + ) { + // Initiate the empty og_image field + onChange(null, "og_image"); + dispatch(fetchFields(modelZUID)); + } + }, [ + isOgImageFieldCreated, + isCreatingOgImageField, + isUndeletingField, + isFieldUndeleted, + ]); + + useEffect(() => { + // Automatically opens the media browser when the og_image field has no value + if (autoOpenMediaBrowser && "og_image" in item?.data) { + if (!item?.data?.["og_image"]) { + fieldTypeMedia.current?.triggerOpenMediaBrowser(); + } + setAutoOpenMediaBrowser(false); + } + }, [item?.data, autoOpenMediaBrowser]); + + // If there is already a field named og_image and it is storing a value + if ( + "og_image" in item?.data && + (!!item?.data?.og_image || showOGImageField) + ) { + const ogImageValue = item.data.og_image; + + return ( + <> + + { + setImageModal(opts); + }} + onChange={(value: string, name: string) => { + if (!value) { + setShowOGImageField(false); + } + + onChange(value, name); + }} + lockedToGroupId={null} + settings={{ + fileExtensions: [ + ".png", + ".jpg", + ".jpeg", + ".svg", + ".gif", + ".tif", + ".webp", + ], + fileExtensionsErrorMessage: + "Only files with the following extensions are allowed: .png, .jpg, .jpeg, .svg, .gif, .tif, .webp", + }} + /> + + {imageModal && ( + + setImageModal(null)} + > + setImageModal(null)} + > + + + { + imageModal.callback(images); + setImageModal(null); + }} + isReplace={imageModal.isReplace} + /> + + + )} + + ); + } + + // If there is a media field with an API ID containing the word "image" and is storing a file + if (!!temporaryMetaImageURL) { + return ( + + + + } + variant="outlined" + sx={{ width: "fit-content" }} + onClick={() => { + handleCreateOgImageField(); + setAutoOpenMediaBrowser(true); + }} + > + Customize Image + + + + ); + } + + // If no image field + return ( + + } + variant="outlined" + sx={{ width: "fit-content", mt: 0.75 }} + onClick={handleCreateOgImageField} + > + Add Meta Image + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx similarity index 56% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx index 30f238dde3..6aa050b170 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/MetaKeywords.js +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaKeywords.tsx @@ -1,17 +1,23 @@ import { memo } from "react"; -import { TextField } from "@mui/material"; +import { TextField, Box } from "@mui/material"; -import { FieldShell } from "../../../../../components/Editor/Field/FieldShell"; -import { MaxLengths } from "../ItemSettings"; -import styles from "./MetaKeywords.less"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaKeywordsProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; export const MetaKeywords = memo(function MetaKeywords({ - meta_keywords, + value, onChange, - errors, -}) { + error, +}: MetaKeywordsProps) { return ( -
    + onChange(evt.target.value, "metaKeywords")} /> -
    + ); }); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx new file mode 100644 index 0000000000..17dc9a1f5a --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaLinkText.tsx @@ -0,0 +1,40 @@ +import { memo } from "react"; + +import { TextField, Box } from "@mui/material"; +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +type MetaLinkTextProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; +}; +export const MetaLinkText = memo(function MetaLinkText({ + value, + onChange, + error, +}: MetaLinkTextProps) { + return ( + + + onChange(evt.target.value, "metaLinkText")} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx new file mode 100644 index 0000000000..fc15fe43f6 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/MetaTitle.tsx @@ -0,0 +1,67 @@ +import { ChangeEvent, memo, MutableRefObject } from "react"; + +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { MaxLengths } from ".."; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { withAI } from "../../../../../../../../shell/components/withAi"; + +const AIFieldShell = withAI(FieldShell); + +type MetaTitleProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + saveMetaTitleParameters?: boolean; + onResetFlowType: () => void; + onAIMetaTitleInserted?: () => void; + aiButtonRef?: MutableRefObject; +}; +export const MetaTitle = memo(function MetaTitle({ + value, + onChange, + error, + saveMetaTitleParameters, + onResetFlowType, + onAIMetaTitleInserted, + aiButtonRef, +}: MetaTitleProps) { + return ( + + ) => { + onChange(evt.target.value, "metaTitle"); + onAIMetaTitleInserted?.(); + }} + onResetFlowType={() => { + onResetFlowType?.(); + }} + > + onChange(evt.target.value, "metaTitle")} + error={hasErrors(error)} + /> + + + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx new file mode 100644 index 0000000000..027728fc81 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGDescription.tsx @@ -0,0 +1,46 @@ +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { ContentModelField } from "../../../../../../../../shell/services/types"; +import { MaxLengths } from ".."; + +type OGDescriptionProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + field: ContentModelField; +}; +export const OGDescription = ({ + value, + onChange, + error, + field, +}: OGDescriptionProps) => { + return ( + + + onChange(evt.target.value, "og_description")} + error={hasErrors(error)} + /> + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx new file mode 100644 index 0000000000..fb28d0323f --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/OGTitle.tsx @@ -0,0 +1,39 @@ +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { ContentModelField } from "../../../../../../../../shell/services/types"; +import { MaxLengths } from ".."; + +type OGTitleProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + field: ContentModelField; +}; +export const OGTitle = ({ value, onChange, error, field }: OGTitleProps) => { + return ( + + + onChange(evt.target.value, "og_title")} + error={hasErrors(error)} + /> + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js new file mode 100644 index 0000000000..66ab8a19cb --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.js @@ -0,0 +1,88 @@ +import { memo } from "react"; + +import { Autocomplete, TextField } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import styles from "./SitemapPriority.less"; + +const OPTIONS = [ + { + value: -1.0, + label: "Automatically Set Priority", + }, + { + value: 1.0, + label: "1.0", + }, + { + value: 0.9, + label: "0.9", + }, + { + value: 0.8, + label: "0.8", + }, + { + value: 0.7, + label: "0.7", + }, + { + value: 0.6, + label: "0.6", + }, + { + value: 0.5, + label: "0.5", + }, + { + value: 0.4, + label: "0.4", + }, + { + value: 0.3, + label: "0.3", + }, + { + value: 0.2, + label: "0.2", + }, + { + value: 0.1, + label: "0.1", + }, + { + value: -2.0, + label: "Do Not Display in Sitemap", + }, +]; + +export const SitemapPriority = memo(function SitemapPriority(props) { + return ( +
    + + option.value === props.sitemapPriority + ) || { + value: -1.0, + label: "Automatically Set Priority", + } + } + fullWidth + renderInput={(params) => } + onChange={(_, value) => { + props.onChange(value ? value.value : -1.0, "sitemapPriority"); + }} + /> + +
    + ); +}); diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.less b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.less similarity index 100% rename from src/apps/content-editor/src/app/views/ItemEdit/Meta/ItemSettings/settings/SitemapPriority.less rename to src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/SitemapPriority.less diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx new file mode 100644 index 0000000000..5084e76406 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCDescription.tsx @@ -0,0 +1,46 @@ +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { ContentModelField } from "../../../../../../../../shell/services/types"; +import { MaxLengths } from "../index"; + +type TCDescriptionProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + field: ContentModelField; +}; +export const TCDescription = ({ + value, + onChange, + error, + field, +}: TCDescriptionProps) => { + return ( + + + onChange(evt.target.value, "tc_description")} + error={hasErrors(error)} + /> + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx new file mode 100644 index 0000000000..3ebab4a1d5 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/TCTitle.tsx @@ -0,0 +1,39 @@ +import { TextField, Box } from "@mui/material"; + +import { FieldShell } from "../../../../components/Editor/Field/FieldShell"; +import { hasErrors } from "./util"; +import { Error } from "../../../../components/Editor/Field/FieldShell"; +import { ContentModelField } from "../../../../../../../../shell/services/types"; +import { MaxLengths } from "../index"; + +type TCTitleProps = { + value: string; + onChange: (value: string, name: string) => void; + error: Error; + field: ContentModelField; +}; +export const TCTitle = ({ value, onChange, error, field }: TCTitleProps) => { + return ( + + + onChange(evt.target.value, "tc_title")} + error={hasErrors(error)} + /> + + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts new file mode 100644 index 0000000000..0aa2242464 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemEdit/Meta/settings/util.ts @@ -0,0 +1,35 @@ +import { Error } from "../../../../components/Editor/Field/FieldShell"; + +export const hasErrors = (errors: Error) => { + if (!errors) return false; + + return Object.values(errors).some((error) => !!error); +}; + +export const validateMetaDescription = (value: string) => { + let message = ""; + + if (!value) return message; + + if (!(value.indexOf("\u0152") === -1)) { + message = + "Found OE ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\u0153") === -1)) { + message = + "Found oe ligature. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xAB") === -1)) { + message = + "Found << character. These special characters are not allowed in meta descriptions."; + } else if (!(value.indexOf("\xBB") === -1)) { + message = + "Found >> character. These special characters are not allowed in meta descriptions."; + } else if (/[\u201C\u201D\u201E]/.test(value)) { + message = + "Found Microsoft Smart double quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead."; + } else if (/[\u2018\u2019\u201A]/.test(value)) { + message = + "Found Microsoft Smart single quotes and/or apostrophe. These special characters are not allowed in meta descriptions as it may lead to incorrect rendering, where characters show up as � or other odd symbols. Please use straight quotes (' and \") instead."; + } + + return message; +}; diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx index 2a9d069f13..07c43a29fd 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/ItemEditHeaderActions.tsx @@ -64,8 +64,8 @@ export const ItemEditHeaderActions = ({ itemZUID: string; }>(); const dispatch = useDispatch(); - const canPublish = usePermission("PUBLISH"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", itemZUID); + const canUpdate = usePermission("UPDATE", itemZUID); const [publishMenu, setPublishMenu] = useState(null); const [publishAfterSave, setPublishAfterSave] = useState(false); const [unpublishDialogOpen, setUnpublishDialogOpen] = useState(false); @@ -229,7 +229,7 @@ export const ItemEditHeaderActions = ({ onClick={() => { onSave(); }} - loading={saving && !publishAfterSave} + loading={saving} disabled={!canUpdate} id="SaveItemButton" > @@ -314,7 +314,7 @@ export const ItemEditHeaderActions = ({ setIsConfirmPublishModalOpen(true); } }} - loading={publishing || publishAfterSave || isFetching} + loading={publishing || saving || isFetching} color="success" variant="contained" id="PublishButton" @@ -332,7 +332,7 @@ export const ItemEditHeaderActions = ({ onClick={(e) => { setPublishMenu(e.currentTarget); }} - disabled={publishing || publishAfterSave || isFetching} + disabled={publishing || saving || isFetching} data-cy="PublishMenuButton" > diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx index 617c83d054..8ed340b481 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/LanguageSelector.tsx @@ -11,6 +11,7 @@ import { useDispatch, useSelector } from "react-redux"; import { ContentItem } from "../../../../../../../../shell/services/types"; import { AppState } from "../../../../../../../../shell/store/types"; import { selectLang } from "../../../../../../../../shell/store/user"; +import getFlagEmoji from "../../../../../../../../utility/getFlagEmoji"; const getCountryCode = (langCode: string) => { const splitTag = langCode.split("-"); @@ -19,18 +20,6 @@ const getCountryCode = (langCode: string) => { return countryCode; }; -const getFlagEmojiFromIETFTag = (langCode: string) => { - const countryCode = getCountryCode(langCode); - - // Convert country code to flag emoji. - // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters. - const baseOffset = 0x1f1e6; - return ( - String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) + - String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65)) - ); -}; - export const LanguageSelector = () => { const dispatch = useDispatch(); const history = useHistory(); @@ -97,7 +86,7 @@ export const LanguageSelector = () => { data-cy="language-selector" > - {getFlagEmojiFromIETFTag(activeLanguage?.code)} + {getFlagEmoji(getCountryCode(activeLanguage?.code))} {" "} {activeLanguage?.code?.split("-")[0]?.toUpperCase()} ( {getCountryCode(activeLanguage?.code)}) @@ -130,7 +119,7 @@ export const LanguageSelector = () => { onSelect(language.code); }} > - {getFlagEmojiFromIETFTag(language.code)}{" "} + {getFlagEmoji(getCountryCode(language.code))}{" "} {language.code.split("-")[0]?.toUpperCase()} ( {getCountryCode(language.code)}) diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx index 0ff94721c6..5a5dcb2ced 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/MoreMenu.tsx @@ -1,10 +1,8 @@ import { - Chip, IconButton, ListItemIcon, Menu, MenuItem, - Typography, Tooltip, } from "@mui/material"; import { @@ -16,23 +14,18 @@ import { CodeRounded, DeleteRounded, CheckRounded, - DesignServicesRounded, - VisibilityRounded, KeyboardArrowRightRounded, } from "@mui/icons-material"; import { useState } from "react"; import { Database } from "@zesty-io/material"; import { useHistory, useParams } from "react-router"; -import { useSelector } from "react-redux"; -import { AppState } from "../../../../../../../../shell/store/types"; -import { ContentItem } from "../../../../../../../../shell/services/types"; import { DuplicateItemDialog } from "./DuplicateItemDialog"; -import { ApiType } from "../../../../../../../schema/src/app/components/ModelApi"; -import { useGetDomainsQuery } from "../../../../../../../../shell/services/accounts"; import { useFilePath } from "../../../../../../../../shell/hooks/useFilePath"; import { DeleteItemDialog } from "./DeleteItemDialog"; import { useGetContentModelsQuery } from "../../../../../../../../shell/services/instance"; import { usePermission } from "../../../../../../../../shell/hooks/use-permissions"; +import { CascadingMenuItem } from "../../../../../../../../shell/components/CascadingMenuItem"; +import { APIEndpoints } from "../../../../components/APIEndpoints"; export const MoreMenu = () => { const { modelZUID, itemZUID } = useParams<{ @@ -42,22 +35,13 @@ export const MoreMenu = () => { const [anchorEl, setAnchorEl] = useState(null); const [isCopied, setIsCopied] = useState(false); const [showDuplicateItemDialog, setShowDuplicateItemDialog] = useState(false); - const [showApiEndpoints, setShowApiEndpoints] = useState( - null - ); const [showDeleteItemDialog, setShowDeleteItemDialog] = useState(false); - const [apiEndpointType, setApiEndpointType] = useState("quick-access"); const history = useHistory(); - const item = useSelector( - (state: AppState) => state.content[itemZUID] as ContentItem - ); - const instance = useSelector((state: AppState) => state.instance); - const { data: domains } = useGetDomainsQuery(); const codePath = useFilePath(modelZUID); const { data: contentModels } = useGetContentModelsQuery(); const type = contentModels?.find((model) => model.ZUID === modelZUID)?.type ?? ""; - const canDelete = usePermission("DELETE"); + const canDelete = usePermission("DELETE", itemZUID); const handleCopyClick = (data: string) => { navigator?.clipboard @@ -73,13 +57,6 @@ export const MoreMenu = () => { }); }; - const apiTypeEndpointMap: Partial> = { - "quick-access": `/-/instant/${itemZUID}.json`, - "site-generators": item ? `/${item?.web?.path}/?toJSON` : "/?toJSON", - }; - - const liveDomain = domains?.find((domain) => domain.branch == "live"); - return ( <> { Copy ZUID - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("quick-access"); - }} + + + + + View Quick Access API + + + } > - - - - View Quick Access API - - + + {type !== "dataset" && ( - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("site-generators"); - }} + + + + + View Site Generators API + + + } > - - - - View Site Generators API - - + + )} { @@ -201,76 +180,6 @@ export const MoreMenu = () => { onClose={() => setShowDuplicateItemDialog(false)} /> )} - { - setShowApiEndpoints(null); - }} - > - { - setShowApiEndpoints(null); - window.open( - // @ts-expect-error config not typed - `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`, - "_blank" - ); - }} - > - - - - - {/* @ts-expect-error config not typed */} - {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`} - - - - {liveDomain && ( - { - setShowApiEndpoints(null); - window.open( - `https://${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`, - "_blank" - ); - }} - > - - - - - {`${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`} - - - - )} - {showDeleteItemDialog && ( setShowDeleteItemDialog(false)} /> )} diff --git a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx index 78653544d4..5f89c8971b 100644 --- a/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemEdit/components/ItemEditHeader/index.tsx @@ -82,8 +82,14 @@ type HeaderProps = { saving: boolean; onSave: () => void; hasError: boolean; + headerTitle: string; }; -export const ItemEditHeader = ({ saving, onSave, hasError }: HeaderProps) => { +export const ItemEditHeader = ({ + saving, + onSave, + hasError, + headerTitle, +}: HeaderProps) => { const { modelZUID, itemZUID } = useParams<{ modelZUID: string; itemZUID: string; @@ -138,7 +144,7 @@ export const ItemEditHeader = ({ saving, onSave, hasError }: HeaderProps) => { overflow: "hidden", }} > - {item?.web?.metaTitle || item?.web?.metaLinkText} + {headerTitle || ""} diff --git a/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx index 3c3772ca65..4fafb2948b 100644 --- a/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/DialogContentItem.tsx @@ -5,6 +5,7 @@ import { ListItemText, ListItemAvatar, Avatar, + Stack, } from "@mui/material"; import { ContentItem } from "../../../../../../shell/services/types"; import { useGetContentModelFieldsQuery } from "../../../../../../shell/services/instance"; @@ -16,6 +17,7 @@ import { import { useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; import { useMemo } from "react"; +import { ImageRounded } from "@mui/icons-material"; export type DialogContentItemProps = { item: ContentItem; @@ -50,26 +52,44 @@ export const DialogContentItem = ({ item }: DialogContentItemProps) => { return ( - - + theme.palette.grey[100], + }} + src={heroImage} + imgProps={{ + style: { + objectFit: "contain", + }, + }} + > + + NA + + + + ) : ( + theme.palette.grey[100], - }} - src={heroImage} - imgProps={{ - style: { - objectFit: "contain", - }, }} > - - NA - - - + + + )} { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); const { data: contentModels } = useGetContentModelsQuery(); - const { data: domains } = useGetDomainsQuery(); const history = useHistory(); const [anchorEl, setAnchorEl] = useState(null); const codePath = useFilePath(modelZUID); const [isCopied, setIsCopied] = useState(false); const [params, setParams] = useParams(); const [searchTerm, setSearchTerm] = useState(params.get("search") || ""); - const instance = useSelector((state: AppState) => state.instance); - const [showApiEndpoints, setShowApiEndpoints] = useState( - null - ); - const [apiEndpointType, setApiEndpointType] = useState("quick-access"); const isDataset = contentModels?.find((model) => model.ZUID === modelZUID)?.type === "dataset"; - const apiTypeEndpointMap: Partial> = { - "quick-access": `/-/instant/${modelZUID}.json`, - "site-generators": "/?toJSON", - }; - const liveDomain = domains?.find((domain) => domain.branch == "live"); const handleCopyClick = (data: string) => { navigator?.clipboard @@ -141,31 +124,33 @@ export const ItemListActions = forwardRef((props, ref) => { Copy ZUID - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("quick-access"); - }} + + + + + View Quick Access API + + + } > - - - - View Quick Access API - - + + {!isDataset && ( - { - setShowApiEndpoints(event.currentTarget); - setApiEndpointType("site-generators"); - }} + + + + + View Site Generators API + + + } > - - - - View Site Generators API - - + + )} { Edit Model - { - history.push(codePath); - }} - > - - - - Edit Template - - - { - setShowApiEndpoints(null); - }} - > - { - setShowApiEndpoints(null); - window.open( - // @ts-expect-error config not typed - `${CONFIG.URL_PREVIEW_PROTOCOL}${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`, - "_blank" - ); - }} - > - - - - - {/* @ts-expect-error config not typed */} - {`${instance.randomHashID}${CONFIG.URL_PREVIEW}${apiTypeEndpointMap[apiEndpointType]}`} - - - - {liveDomain && ( + {!isDataset && ( { - setShowApiEndpoints(null); - window.open( - `https://${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`, - "_blank" - ); + history.push(codePath); }} > - + - - {`${liveDomain.domain}${ - apiTypeEndpointMap[ - apiEndpointType as keyof typeof apiTypeEndpointMap - ] - }`} - - + Edit Template )} diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx index b791dae062..75d836eea0 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListFilters.tsx @@ -1,12 +1,23 @@ -import { Box, Menu, MenuItem, Button, Typography } from "@mui/material"; +import { + Box, + Menu, + MenuItem, + Button, + Typography, + MenuList, + ListItemText, +} from "@mui/material"; import { DateFilter, FilterButton, UserFilter, } from "../../../../../../shell/components/Filters"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, useContext } from "react"; import { useParams } from "../../../../../../shell/hooks/useParams"; -import { ArrowDropDownOutlined } from "@mui/icons-material"; +import { + ChevronRightOutlined, + KeyboardArrowDownRounded, +} from "@mui/icons-material"; import { useGetContentModelFieldsQuery, useGetLangsQuery, @@ -14,12 +25,14 @@ import { import { useDateFilterParams } from "../../../../../../shell/hooks/useDateFilterParams"; import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; import { useParams as useRouterParams } from "react-router"; +import { CascadingMenuItem } from "../../../../../../shell/components/CascadingMenuItem"; +import { TableSortContext } from "./TableSortProvider"; const SORT_ORDER = { - dateSaved: "Date Saved", - datePublished: "Date Published", - dateCreated: "Date Created", - status: "Status", + lastSaved: "Last Saved", + lastPublished: "Last Published", + createdOn: "Date Created", + version: "Status", } as const; const STATUS_FILTER = { @@ -87,6 +100,9 @@ export const ItemListFilters = () => { const { data: users } = useGetUsersQuery(); const { data: fields, isFetching: isFieldsFetching } = useGetContentModelFieldsQuery(modelZUID); + const [sortModel, setSortModel] = useContext(TableSortContext); + + const activeSortOrder = sortModel?.[0]?.field; const userOptions = useMemo(() => { return users?.map((user) => ({ @@ -97,17 +113,49 @@ export const ItemListFilters = () => { })); }, [users]); + const handleUpdateSortOrder = (sortType: string) => { + setAnchorEl({ + currentTarget: null, + id: "", + }); + + setSortModel([ + { + field: sortType, + sort: "desc", + }, + ]); + }; + + const getButtonText = (activeSortOrder: string) => { + if (!activeSortOrder) { + return SORT_ORDER.lastSaved; + } + + if (activeSortOrder === "createdBy") { + return "Created By"; + } + + if (activeSortOrder === "zuid") { + return "ZUID"; + } + + if (SORT_ORDER.hasOwnProperty(activeSortOrder)) { + return SORT_ORDER[activeSortOrder as keyof typeof SORT_ORDER]; + } + + const fieldLabel = fields?.find( + (field) => field.name === activeSortOrder + )?.label; + return fieldLabel; + }; + return ( field.name === params.get("sort")) - ?.label) ?? - SORT_ORDER.dateSaved - }`} + buttonText={`Sort: ${getButtonText(activeSortOrder)}`} onOpenMenu={(event: React.MouseEvent) => { setAnchorEl({ currentTarget: event.currentTarget, @@ -134,23 +182,46 @@ export const ItemListFilters = () => { > {Object.entries(SORT_ORDER).map(([key, value]) => ( { - setParams(key, "sort"); - setAnchorEl({ - currentTarget: null, - id: "", - }); - }} + onClick={() => handleUpdateSortOrder(key)} selected={ - key === "dateSaved" - ? !params.get("sort") || params.get("sort") === key - : params.get("sort") === key + key === "lastSaved" + ? !activeSortOrder || activeSortOrder === "lastSaved" + : activeSortOrder === key } > {value} ))} + + More + + + } + PaperProps={{ + sx: { + width: 240, + }, + }} + > + + handleUpdateSortOrder("createdBy")} + > + Created By + + handleUpdateSortOrder("zuid")} + > + ZUID + + + { ?.map((field) => ( { - setParams(field.name, "sort"); - setAnchorEl({ - currentTarget: null, - id: "", - }); - }} - selected={params.get("sort") === field.name} + onClick={() => handleUpdateSortOrder(field.name)} + selected={activeSortOrder === field.name} > {field.label} @@ -217,6 +282,7 @@ export const ItemListFilters = () => { > {Object.entries(STATUS_FILTER).map(([key, value]) => ( { setParams(key, "statusFilter"); @@ -255,7 +321,7 @@ export const ItemListFilters = () => { size="small" variant="outlined" color="inherit" - endIcon={} + endIcon={} onClick={(e) => setAnchorEl({ currentTarget: e.currentTarget, diff --git a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx index ca8ec799d0..377fa7dc30 100644 --- a/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/ItemListTable.tsx @@ -16,8 +16,17 @@ import { GRID_CHECKBOX_SELECTION_COL_DEF, useGridApiRef, GridInitialState, + GridComparatorFn, + GridPinnedColumns, } from "@mui/x-data-grid-pro"; -import { memo, useCallback, useLayoutEffect, useMemo, useState } from "react"; +import { + memo, + useCallback, + useLayoutEffect, + useMemo, + useState, + useContext, +} from "react"; import { ContentItem } from "../../../../../../shell/services/types"; import { useStagedChanges } from "./StagedChangesContext"; import { OneToManyCell } from "./TableCells/OneToManyCell"; @@ -27,14 +36,30 @@ import { VersionCell } from "./TableCells/VersionCell"; import { DropDownCell } from "./TableCells/DropdownCell"; import { SortCell } from "./TableCells/SortCell"; import { BooleanCell } from "./TableCells/BooleanCell"; +import { currencies } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; +import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; import { ImageCell } from "./TableCells/ImageCell"; import { SingleRelationshipCell } from "./TableCells/SingleRelationshipCell"; +import { useParams } from "../../../../../../shell/hooks/useParams"; +import { TableSortContext } from "./TableSortProvider"; type ItemListTableProps = { loading: boolean; rows: ContentItem[]; }; +const CURRENCY_OBJECT: Record = currencies.reduce( + (acc, curr) => { + return { + ...acc, + [curr.value]: { + ...curr, + }, + }; + }, + {} +); + const getHtmlText = (html: string) => { if (!html) return ""; @@ -51,15 +76,13 @@ const METADATA_COLUMNS = [ field: "createdBy", headerName: "Created By", width: 240, - sortable: false, filterable: false, renderCell: (params: GridRenderCellParams) => , }, { field: "createdOn", - headerName: "Created On", + headerName: "Date Created", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.meta?.createdAt, }, @@ -68,7 +91,6 @@ const METADATA_COLUMNS = [ field: "lastSaved", headerName: "Last Saved", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.web?.updatedAt, }, @@ -76,7 +98,6 @@ const METADATA_COLUMNS = [ field: "lastPublished", headerName: "Last Published", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.publishing?.publishAt, }, @@ -84,7 +105,6 @@ const METADATA_COLUMNS = [ field: "zuid", headerName: "ZUID", width: 200, - sortable: false, filterable: false, valueGetter: (params: any) => params.row?.meta?.ZUID, }, @@ -136,11 +156,14 @@ const fieldTypeColumnConfigMap = { currency: { width: 160, valueFormatter: (params: any) => { - if (!params.value) return null; - return new Intl.NumberFormat("en-US", { - style: "currency", - currency: "USD", - }).format(params.value); + if (params.value?.value === undefined || params.value?.value === null) + return ""; + + return `${ + CURRENCY_OBJECT[params.value?.currency]?.symbol_native + } ${new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + }).format(params.value.value)}`; }, align: "right", }, @@ -229,6 +252,9 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { const history = useHistory(); const { stagedChanges } = useStagedChanges(); const [selectedItems, setSelectedItems] = useSelectedItems(); + const [params, setParams] = useParams(); + const [sortModel, setSortModel] = useContext(TableSortContext); + const [pinnedColumns, setPinnedColumns] = useState({}); const { data: fields } = useGetContentModelFieldsQuery(modelZUID); @@ -249,18 +275,11 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { ); setInitialState( - stateFromLocalStorage - ? JSON.parse(stateFromLocalStorage) - : { - pinnedColumns: { - left: [ - GRID_CHECKBOX_SELECTION_COL_DEF.field, - "version", - fields?.[0]?.name, - ], - }, - } + stateFromLocalStorage ? JSON.parse(stateFromLocalStorage) : {} ); + setPinnedColumns({ + left: ["__check__", "version", fields?.[0]?.name], + }); window.addEventListener("beforeunload", saveSnapshot); @@ -276,7 +295,7 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { field: "version", headerName: "Vers.", width: 59, - sortable: false, + sortable: true, filterable: false, renderCell: (params: GridRenderCellParams) => ( @@ -287,13 +306,21 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { result = [ ...result, ...fields - ?.filter((field) => !field.deletedAt) + ?.filter((field) => !field.deletedAt && field?.settings?.list) ?.map((field) => ({ field: field.name, headerName: field.label, - sortable: false, filterable: false, - valueGetter: (params: any) => params.row.data[field.name], + valueGetter: (params: any) => { + if (field.datatype === "currency") { + return { + value: params.row.data[field.name], + currency: field.settings?.currency || "USD", + }; + } + + return params.row.data[field.name]; + }, ...fieldTypeColumnConfigMap[field.datatype], // if field is yes_no but it has custom options increase the width ...(field.datatype === "yes_no" && @@ -326,6 +353,10 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { loading={loading} rows={rows} columns={[...columns, ...METADATA_COLUMNS]} + pinnedColumns={pinnedColumns} + onPinnedColumnsChange={(newPinnedColumns) => + setPinnedColumns(newPinnedColumns) + } rowHeight={54} hideFooter onRowClick={(row) => { @@ -370,6 +401,21 @@ export const ItemListTable = memo(({ loading, rows }: ItemListTableProps) => { checkboxSelection disableSelectionOnClick initialState={initialState} + sortingOrder={["desc", "asc", null]} + sortModel={sortModel} + sortingMode="server" + onSortModelChange={(newSortModel) => { + if (!Object.entries(newSortModel)?.length) { + setSortModel([ + { + field: "lastSaved", + sort: "desc", + }, + ]); + } else { + setSortModel(newSortModel); + } + }} onSelectionModelChange={(newSelection) => setSelectedItems(newSelection)} selectionModel={ stagedChanges && Object.keys(stagedChanges)?.length ? [] : selectedItems diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx index 13a40c5ced..706213a9be 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/DropdownCell.tsx @@ -10,7 +10,14 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { const field = params.row.fieldData[params.field]; const handleChange = (value: any) => { setAnchorEl(null); - updateStagedChanges(params.row.id, params.field, value); + + if (value !== currVal) { + updateStagedChanges( + params.row.id, + params.field, + value === "Select" ? null : value + ); + } }; const currVal = @@ -60,7 +67,7 @@ export const DropDownCell = ({ params }: { params: GridRenderCellParams }) => { { - handleChange(null); + handleChange("Select"); }} sx={{ textWrap: "wrap", diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx index 8c8df58e85..70f1a4ef24 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/OneToManyCell.tsx @@ -29,22 +29,14 @@ type OneToManyCellProps = { items: any[]; }; export const OneToManyCell = ({ items }: OneToManyCellProps) => { - const dispatch = useDispatch(); const allItems = useSelector((state: AppState) => state.content); const chipContainerRef = useRef(); - const [lastValidIndex, setLastValidIndex] = useState(allItems?.length - 1); + const [lastValidIndex, setLastValidIndex] = useState( + Object.keys(allItems)?.length - 1 + ); const parentWidth = chipContainerRef.current?.parentElement?.clientWidth; const hiddenItems = items?.length - lastValidIndex - 1; - useEffect(() => { - items?.forEach((item) => { - // If value starts with '7-', that means it was unable to find the item in the store so we need to fetch it - if (item?.startsWith("7-")) { - dispatch(searchItems(item)); - } - }); - }, [items, dispatch]); - useEffect(() => { setLastValidIndex( getNumOfItemsToRender(parentWidth, chipContainerRef.current?.children) @@ -54,15 +46,11 @@ export const OneToManyCell = ({ items }: OneToManyCellProps) => { return ( <> - {items?.slice(0, lastValidIndex + 1)?.map((id: string) => { - return ( - - ); - })} + {items + ?.slice(0, lastValidIndex + 1) + ?.map((id: string, index: number) => { + return ; + })} {!!hiddenItems && ( { {/** Element below is only needed to calculate the actual chip widths */} - {items?.map((id: string) => { - return ( - - ); + {items?.map((id: string, index: number) => { + return ; })} diff --git a/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx b/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx index d952e5a12f..5963905d67 100644 --- a/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/TableCells/SingleRelationshipCell.tsx @@ -1,20 +1,10 @@ import { GridRenderCellParams } from "@mui/x-data-grid-pro"; import { Chip } from "@mui/material"; -import { useEffect } from "react"; -import { useDispatch } from "react-redux"; -import { searchItems } from "../../../../../../../shell/store/content"; export const SingleRelationshipCell = ({ params, }: { params: GridRenderCellParams; }) => { - const dispatch = useDispatch(); - useEffect(() => { - // If value starts with '7-', that means it was unable to find the item in the store so we need to fetch it - if (params.value?.startsWith("7-")) { - dispatch(searchItems(params.value)); - } - }, [params.value, dispatch]); return ; }; diff --git a/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx new file mode 100644 index 0000000000..bb6bee3b40 --- /dev/null +++ b/src/apps/content-editor/src/app/views/ItemList/TableSortProvider.tsx @@ -0,0 +1,53 @@ +import { useState, createContext, useLayoutEffect } from "react"; +import { GridSortModel, GridSortItem } from "@mui/x-data-grid-pro"; +import { useParams as useRouterParams } from "react-router"; + +type TableSortContextType = [ + GridSortModel, + (newSortModel: GridSortModel) => void +]; +export const TableSortContext = createContext([ + [], + () => {}, +]); + +type TableSortProviderType = { + children?: React.ReactNode; +}; +export const TableSortProvider = ({ children }: TableSortProviderType) => { + // Note: We always want it to default to lastSaved if no other sorting is applied + const [sortModel, setSortModel] = useState([ + { + field: "lastSaved", + sort: "desc", + }, + ]); + const { modelZUID } = useRouterParams<{ modelZUID: string }>(); + + useLayoutEffect(() => { + if (!modelZUID) return; + + const stateFromLocalStorage = localStorage?.getItem( + `${modelZUID}-dataGridState` + ); + + if (stateFromLocalStorage) { + const { sortModel: sortModelFromLocalStorage } = JSON.parse( + stateFromLocalStorage + )?.sorting; + + if ( + Array.isArray(sortModelFromLocalStorage) && + sortModelFromLocalStorage?.length + ) { + setSortModel(sortModelFromLocalStorage); + } + } + }, [modelZUID]); + + return ( + + {children} + + ); +}; diff --git a/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx index 891cb6e7dc..c42cab140f 100644 --- a/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/UpdateListActions.tsx @@ -46,9 +46,9 @@ type UpdateListActionsProps = { export const UpdateListActions = ({ items }: UpdateListActionsProps) => { const { modelZUID } = useRouterParams<{ modelZUID: string }>(); - const canPublish = usePermission("PUBLISH"); - const canDelete = usePermission("DELETE"); - const canUpdate = usePermission("UPDATE"); + const canPublish = usePermission("PUBLISH", modelZUID); + const canDelete = usePermission("DELETE", modelZUID); + const canUpdate = usePermission("UPDATE", modelZUID); const dispatch = useDispatch(); const [anchorEl, setAnchorEl] = useState(null); const [itemsToPublish, setItemsToPublish] = useState([]); diff --git a/src/apps/content-editor/src/app/views/ItemList/index.tsx b/src/apps/content-editor/src/app/views/ItemList/index.tsx index 8e572918f7..73f9fcf42a 100644 --- a/src/apps/content-editor/src/app/views/ItemList/index.tsx +++ b/src/apps/content-editor/src/app/views/ItemList/index.tsx @@ -9,7 +9,14 @@ import { import { theme } from "@zesty-io/material"; import { ItemListEmpty } from "./ItemListEmpty"; import { ItemListActions } from "./ItemListActions"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { + useEffect, + useMemo, + useRef, + useState, + useContext, + useCallback, +} from "react"; import { SearchRounded, RestartAltRounded } from "@mui/icons-material"; import noSearchResults from "../../../../../../../public/images/noSearchResults.svg"; import { ItemListFilters } from "./ItemListFilters"; @@ -29,8 +36,11 @@ import { useGetUsersQuery } from "../../../../../../shell/services/accounts"; import { ContentItem, ContentItemWithDirtyAndPublishing, + ContentModelFieldDataType, } from "../../../../../../shell/services/types"; import { fetchItems } from "../../../../../../shell/store/content"; +import { TableSortContext } from "./TableSortProvider"; +import { fetchFields } from "../../../../../../shell/store/fields"; const formatDateTime = (source: string) => { const dateObj = new Date(source); @@ -93,15 +103,17 @@ export const ItemList = () => { const items = useSelector((state: AppState) => selectFilteredItems(state, modelZUID, activeLangId, !hasMounted) ); + const allFields = useSelector((state: AppState) => state.fields); const { data: users, isFetching: isUsersFetching } = useGetUsersQuery(); const [isModelItemsFetching, setIsModelItemsFetching] = useState(true); + const [sortModel] = useContext(TableSortContext); const { stagedChanges } = useStagedChanges(); const [selectedItems] = useSelectedItems(); const searchRef = useRef(null); const search = params.get("search"); - const sort = params.get("sort"); + // const sort = params.get("sort"); const statusFilter = params.get("statusFilter"); const dateFilter = useMemo(() => { return { @@ -112,7 +124,44 @@ export const ItemList = () => { }, [params]); const userFilter = params.get("user"); + const resolveFieldRelationshipTitle = useCallback( + ( + fieldName: string, + fieldDataType: ContentModelFieldDataType, + relatedContentItemZUID: string + ) => { + if ( + !fields?.length || + !allFields || + !allItems || + !fieldName || + !fieldDataType || + !relatedContentItemZUID + ) { + return; + } + + // Finds the related field zuid that's stored in the specific field's data + const fieldData = fields?.find( + (field) => + field.name === fieldName && + !field.deletedAt && + field.datatype === fieldDataType + ); + + // Gets the data of the related field determined above + const relatedFieldData = allFields?.[fieldData?.relatedFieldZUID]; + + return ( + allItems?.[relatedContentItemZUID]?.data?.[relatedFieldData?.name] ?? + relatedContentItemZUID + ); + }, + [allItems, fields, allFields, modelZUID] + ); + useEffect(() => { + dispatch(fetchFields(modelZUID)); setTimeout(() => { setHasMounted(true); }, 0); @@ -213,12 +262,16 @@ export const ItemList = () => { break; case "internal_link": case "one_to_one": - clonedItem.data[key] = allItems?.[value]?.web?.metaTitle || value; + clonedItem.data[key] = resolveFieldRelationshipTitle( + key, + fieldType, + value + ); break; case "one_to_many": clonedItem.data[key] = value ?.split(",") - ?.map((id) => allItems?.[id]?.web?.metaTitle || id) + ?.map((id) => resolveFieldRelationshipTitle(key, fieldType, id)) ?.join(","); break; case "date": @@ -243,9 +296,11 @@ export const ItemList = () => { }, [items, allItems, fields, users, isFieldsFetching, isUsersFetching]); const sortedAndFilteredItems = useMemo(() => { + const sort = sortModel?.[0]?.field; + const sortOrder = sortModel?.[0]?.sort; let clonedItems = [...processedItems]; clonedItems?.sort((a: any, b: any) => { - if (!sort || sort === "dateSaved") { + if (!sort || sort === "lastSaved") { const dateA = new Date(a.web.createdAt).getTime(); const dateB = new Date(b.web.createdAt).getTime(); @@ -254,9 +309,9 @@ export const ItemList = () => { } else if (!b.web.createdAt) { return 1; } else { - return dateB - dateA; + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; } - } else if (sort === "datePublished") { + } else if (sort === "lastPublished") { // Handle undefined publishAt by setting a default far-future date for sorting purposes let dateA = a?.scheduling?.publishAt || a?.publishing?.publishAt; @@ -265,13 +320,20 @@ export const ItemList = () => { let dateB = b?.scheduling?.publishAt || b?.publishing?.publishAt; dateB = dateB ? new Date(dateB).getTime() : Number.NEGATIVE_INFINITY; - return dateB - dateA; - } else if (sort === "dateCreated") { + return sortOrder === "asc" ? dateA - dateB : dateB - dateA; + } else if (sort === "createdOn") { + if (sortOrder === "asc") { + return ( + new Date(a.meta.createdAt).getTime() - + new Date(b.meta.createdAt).getTime() + ); + } + return ( new Date(b.meta.createdAt).getTime() - new Date(a.meta.createdAt).getTime() ); - } else if (sort === "status") { + } else if (sort === "version") { const aIsPublished = a?.publishing?.publishAt; const bIsPublished = b?.publishing?.publishAt; @@ -291,6 +353,13 @@ export const ItemList = () => { // Items with only publish date if (aIsPublished && !aIsScheduled && bIsPublished && !bIsScheduled) { + if (sortOrder === "asc") { + return ( + new Date(aIsPublished).getTime() - + new Date(bIsPublished).getTime() + ); + } + return ( new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() ); // Both have only published date, sort by publish date descending @@ -302,6 +371,13 @@ export const ItemList = () => { // Items with scheduled date (and also publish date) if (aIsScheduled && bIsScheduled) { + if (sortOrder === "asc") { + return ( + new Date(bIsScheduled).getTime() - + new Date(aIsScheduled).getTime() + ); + } + return ( new Date(aIsScheduled).getTime() - new Date(bIsScheduled).getTime() ); // Both are scheduled, sort by scheduled date ascending @@ -313,6 +389,13 @@ export const ItemList = () => { // Items with neither publish nor schedule dates if (aIsPublished && bIsPublished) { + if (sortOrder === "asc") { + return ( + new Date(aIsPublished).getTime() - + new Date(bIsPublished).getTime() + ); + } + return ( new Date(bIsPublished).getTime() - new Date(aIsPublished).getTime() ); // Both are published, sort by publish date descending @@ -323,31 +406,77 @@ export const ItemList = () => { } return 0; // Neither are published or scheduled + } else if (sort === "createdBy") { + const userA = a?.meta?.createdByUserName; + const userB = b?.meta?.createdByUserName; + + if (!userA) { + return 1; + } else if (!userB) { + return -1; + } else { + return sortOrder === "asc" + ? userB.localeCompare(userA) + : userA.localeCompare(userB); + } + } else if (sort === "zuid") { + return sortOrder === "asc" + ? b.meta?.ZUID?.localeCompare(a.meta?.ZUID) + : a.meta?.ZUID?.localeCompare(b.meta?.ZUID); } else if (fields?.find((field) => field.name === sort)) { const dataType = fields?.find((field) => field.name === sort)?.datatype; if (typeof a.data[sort] === "number") { if (a.data[sort] == null) return 1; if (b.data[sort] == null) return -1; - return dataType === "sort" + if (dataType === "sort") { + return sortOrder === "asc" + ? a.data[sort] - b.data[sort] + : b.data[sort] - a.data[sort]; + } + + return sortOrder === "asc" ? a.data[sort] - b.data[sort] : b.data[sort] - a.data[sort]; } if (dataType === "date" || dataType === "datetime") { - return ( - new Date(b.data[sort]).getTime() - new Date(a.data[sort]).getTime() - ); + if (!a.data[sort]) { + return 1; + } else if (!b.data[sort]) { + return -1; + } else { + return sortOrder === "asc" + ? new Date(a.data[sort]).getTime() - + new Date(b.data[sort]).getTime() + : new Date(b.data[sort]).getTime() - + new Date(a.data[sort]).getTime(); + } + } + + if (dataType === "yes_no") { + if (!a.data[sort]) { + return 1; + } else if (!b.data[sort]) { + return -1; + } else { + return sortOrder === "asc" ? a - b : b - a; + } } + const aValue = dataType === "images" ? a.data[sort]?.filename : a.data[sort]; const bValue = dataType === "images" ? b.data[sort]?.filename : b.data[sort]; - return aValue?.trim()?.localeCompare(bValue?.trim()); + + return sortOrder === "asc" + ? bValue?.trim()?.localeCompare(aValue?.trim()) + : aValue?.trim()?.localeCompare(bValue?.trim()); } else { - return ( - new Date(b.meta.updatedAt).getTime() - - new Date(a.meta.updatedAt).getTime() - ); + return sortOrder === "asc" + ? new Date(a.meta.updatedAt).getTime() - + new Date(b.meta.updatedAt).getTime() + : new Date(b.meta.updatedAt).getTime() - + new Date(a.meta.updatedAt).getTime(); } }); if (search) { @@ -415,7 +544,7 @@ export const ItemList = () => { // filter items by all fields return clonedItems; - }, [processedItems, search, sort, statusFilter, dateFilter, userFilter]); + }, [processedItems, search, sortModel, statusFilter, dateFilter, userFilter]); return ( @@ -527,7 +656,7 @@ export const ItemList = () => { {!sortedAndFilteredItems?.length && !isModelItemsFetching && !search && - (sort || + (!!sortModel?.length || statusFilter || dateFilter?.preset || dateFilter?.from || diff --git a/src/apps/media/src/app/components/Controls/DateFilter.tsx b/src/apps/media/src/app/components/Controls/DateFilter.tsx index abbf11235c..000ceca456 100644 --- a/src/apps/media/src/app/components/Controls/DateFilter.tsx +++ b/src/apps/media/src/app/components/Controls/DateFilter.tsx @@ -7,7 +7,7 @@ import MenuItem from "@mui/material/MenuItem"; import Menu from "@mui/material/Menu"; import Typography from "@mui/material/Typography"; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; import CloseRounded from "@mui/icons-material/CloseRounded"; import CheckIcon from "@mui/icons-material/Check"; import Divider from "@mui/material/Divider"; @@ -98,7 +98,7 @@ export const DateRangeFilter: FC = () => { const inactiveButton = ( + + + ); + } + + return ( + <> + + + + + + + + Replace File: + +   + {originalFile?.filename} + + + + + The original file will be deleted and replaced by its new file. This + action cannot be undone and the file cannot be recovered. The file + will retain its URL and ZUID. + + + + + + + + { + setNewFile(evt.target.files[0]); + }} + hidden + accept={acceptedExtension} + style={{ display: "none" }} + /> + + ); +}; diff --git a/src/apps/media/src/app/components/FileModal/index.tsx b/src/apps/media/src/app/components/FileModal/index.tsx index e726957cd5..282c6877d0 100644 --- a/src/apps/media/src/app/components/FileModal/index.tsx +++ b/src/apps/media/src/app/components/FileModal/index.tsx @@ -18,6 +18,7 @@ import { useGetFileQuery } from "../../../../../../shell/services/mediaManager"; import { OTFEditor } from "./OTFEditor"; import { File } from "../../../../../../shell/services/types"; import { useParams } from "../../../../../../shell/hooks/useParams"; +import { ReplaceFileModal } from "./ReplaceFileModal"; const styledModal = { position: "absolute", @@ -48,6 +49,8 @@ export const FileModal: FC = ({ const location = useLocation(); const { data, isLoading, isError, isFetching } = useGetFileQuery(fileId); const [showEdit, setShowEdit] = useState(false); + const [showReplaceFileModal, setShowReplaceFileModal] = useState(false); + const [fileToReplace, setFileToReplace] = useState(null); const [params, setParams] = useParams(); const [adjacentFiles, setAdjacentFiles] = useState({ prevFile: null, @@ -150,128 +153,145 @@ export const FileModal: FC = ({ }; }, []); + if (isFetching || (!data && !isError)) { + return ( + + + + ); + } + + if (showReplaceFileModal) { + return ( + setShowReplaceFileModal(false)} + onClose={handleCloseModal} + /> + ); + } + + if (!data) { + return <>; + } + return ( - <> - {data && !isError && !isFetching ? ( - + {adjacentFiles.nextFile && ( + { + handleArrow(adjacentFiles.nextFile); + }} + sx={{ + position: "absolute", + right: -72, + top: "50%", }} > - {adjacentFiles.nextFile && ( - { - handleArrow(adjacentFiles.nextFile); - }} - sx={{ - position: "absolute", - right: -72, - top: "50%", - }} - > - - - )} - {adjacentFiles.prevFile && ( - - { - handleArrow(adjacentFiles.prevFile); - }} - sx={{ - position: "absolute", - left: -72, - top: "50%", - }} - > - - - - )} - + + )} + {adjacentFiles.prevFile && ( + + { + handleArrow(adjacentFiles.prevFile); + }} sx={{ - display: "flex", - justifyContent: "space-between", - p: 0, - overflow: "hidden", + position: "absolute", + left: -72, + top: "50%", }} > - {/* */} - - - - - - {showEdit ? ( - - ) : ( - - )} - - {/* */} - - - ) : isFetching || (!data && !isError) ? ( - + + + )} + + {/* */} + - - - ) : ( - <> - )} - + + + + + {showEdit ? ( + + ) : ( + { + setShowReplaceFileModal(true); + }} + /> + )} + + {/* */} + + ); }; diff --git a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx index 6ce30ef6a5..009ff0de1e 100644 --- a/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx +++ b/src/apps/media/src/app/components/Thumbnail/ThumbnailContent.tsx @@ -17,16 +17,20 @@ interface Props { filename: string; onFilenameChange?: (value: string) => void; onTitleChange?: (value: string) => void; - isEditable?: boolean; isSelected?: boolean; + isFilenameEditable?: boolean; + isTitleEditable?: boolean; + title?: string; } export const ThumbnailContent: FC = ({ filename, onFilenameChange, onTitleChange, - isEditable, isSelected, + isFilenameEditable, + isTitleEditable, + title, }) => { const styledCardContent = { px: onFilenameChange ? 0 : 1, @@ -48,13 +52,16 @@ export const ThumbnailContent: FC = ({ {onFilenameChange ? ( - + ) => onFilenameChange(e.target.value.replace(" ", "-")) } @@ -79,15 +86,16 @@ export const ThumbnailContent: FC = ({ }, }} /> - + void; onTitleChange?: (value: string) => void; onClick?: () => void; + showRemove?: boolean; + isFilenameEditable?: boolean; + isTitleEditable?: boolean; + title?: string; } export const Thumbnail: FC = ({ src, url, filename, - isEditable, + isDraggable, showVideo, onRemove, onFilenameChange, @@ -83,6 +87,10 @@ export const Thumbnail: FC = ({ onTitleChange, imageHeight, selectable, + showRemove = true, + isFilenameEditable, + isTitleEditable, + title, }) => { const theme = useTheme(); const imageEl = useRef(); @@ -119,6 +127,10 @@ export const Thumbnail: FC = ({ }; const RemoveIcon = () => { + if (!showRemove) { + return <>; + } + return ( <> {onRemove && ( @@ -331,7 +343,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} data-cy={id} > @@ -408,10 +420,12 @@ export const Thumbnail: FC = ({ file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -425,7 +439,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} data-cy={id} onDragStart={(evt) => onDragStart(evt)} > @@ -503,8 +517,9 @@ export const Thumbnail: FC = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -516,7 +531,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -588,7 +604,7 @@ export const Thumbnail: FC = ({ sx={styledCard} elevation={0} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -663,7 +680,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -737,7 +755,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -812,7 +831,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -889,7 +909,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -972,7 +993,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1069,7 +1091,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1147,7 +1170,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1220,7 +1244,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1293,7 +1318,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1366,7 +1392,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1439,7 +1466,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1515,7 +1543,7 @@ export const Thumbnail: FC = ({ elevation={0} data-cy={id} onClick={isSelecting ? handleSelect : onClick} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1595,7 +1624,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1675,7 +1705,7 @@ export const Thumbnail: FC = ({ elevation={0} onClick={isSelecting ? handleSelect : onClick} data-cy={id} - draggable={!isEditable} + draggable={!isDraggable} onDragStart={(evt) => onDragStart(evt)} > = ({ filename={filename} onFilenameChange={onFilenameChange} onTitleChange={onTitleChange} - isEditable={isEditable} isSelected={selectedFiles.some((file) => file.id === id)} + isTitleEditable={isTitleEditable} + isFilenameEditable={isFilenameEditable} /> ); @@ -1759,6 +1790,6 @@ export const Thumbnail: FC = ({ }; Thumbnail.defaultProps = { - isEditable: false, + isDraggable: false, showVideo: true, }; diff --git a/src/apps/media/src/app/components/UploadModal.tsx b/src/apps/media/src/app/components/UploadModal.tsx index d7cee6d083..3c2c4e0eec 100644 --- a/src/apps/media/src/app/components/UploadModal.tsx +++ b/src/apps/media/src/app/components/UploadModal.tsx @@ -31,9 +31,13 @@ import pluralizeWord from "../../../../../utility/pluralizeWord"; export const UploadModal: FC = () => { const dispatch = useDispatch(); - const uploads = useSelector((state: AppState) => state.mediaRevamp.uploads); + const uploads = useSelector((state: AppState) => + state.mediaRevamp.uploads.filter((upload) => !upload.replacementFile) + ); const filesToUpload = useSelector((state: AppState) => - state.mediaRevamp.uploads.filter((upload) => upload.status !== "failed") + state.mediaRevamp.uploads.filter( + (upload) => upload.status !== "failed" && !upload.replacementFile + ) ); const ids = filesToUpload.length && { currentBinId: filesToUpload[0].bin_id, @@ -186,8 +190,14 @@ const UploadErrors = () => { type UploadHeaderTextProps = { uploads: Upload[]; + headerKeyword?: string; + showCount?: boolean; }; -const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => { +export const UploadHeaderText = ({ + uploads, + headerKeyword = "File", + showCount = true, +}: UploadHeaderTextProps) => { const filesUploading = uploads?.filter( (upload) => upload.status === "inProgress" ); @@ -225,12 +235,18 @@ const UploadHeaderText = ({ uploads }: UploadHeaderTextProps) => { )} + {showCount ? ( + filesUploading?.length > 0 ? ( + filesUploading.length + ) : ( + filesUploaded.length + ) + ) : ( + <> + )}{" "} {filesUploading?.length > 0 - ? filesUploading.length - : filesUploaded.length}{" "} - {filesUploading?.length > 0 - ? pluralizeWord("File", filesUploading.length) - : pluralizeWord("File", filesUploaded.length)}{" "} + ? pluralizeWord(headerKeyword, filesUploading.length) + : pluralizeWord(headerKeyword, filesUploaded.length)}{" "} {filesUploading?.length > 0 ? "Uploading" : "Uploaded"} diff --git a/src/apps/media/src/app/components/UploadThumbnail.tsx b/src/apps/media/src/app/components/UploadThumbnail.tsx index 83504e031f..35d7f0b5c2 100644 --- a/src/apps/media/src/app/components/UploadThumbnail.tsx +++ b/src/apps/media/src/app/components/UploadThumbnail.tsx @@ -13,13 +13,23 @@ import { Upload, fileUploadSetFilename, deleteUpload, + replaceFile, } from "../../../../../shell/store/media-revamp"; +import { File as ZestyMediaFile } from "../../../../../shell/services/types"; interface Props { file: Upload; + action?: "new" | "replace"; + originalFile?: ZestyMediaFile; + showRemove?: boolean; } -export const UploadThumbnail: FC = ({ file }) => { +export const UploadThumbnail: FC = ({ + file, + action = "new", + originalFile, + showRemove = true, +}) => { const dispatch = useDispatch(); const { data: bin } = mediaManagerApi.useGetBinQuery(file.bin_id, { @@ -28,7 +38,11 @@ export const UploadThumbnail: FC = ({ file }) => { useEffect(() => { if (bin && file.status === "staged") { - dispatch(uploadFile(file, bin[0])); + if (action === "new") { + dispatch(uploadFile(file, bin[0])); + } else { + dispatch(replaceFile(file, originalFile)); + } } }, [bin]); @@ -61,10 +75,14 @@ export const UploadThumbnail: FC = ({ file }) => { > { if (file.status === "success") { dispatch( diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx index 4a57292a00..d0b6a5427a 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValue.tsx @@ -27,6 +27,7 @@ type DefaultValueProps = { relatedFieldZUID: string; }; options: FieldSettingsOptions[]; + currency?: string; }; export const DefaultValue = ({ @@ -39,6 +40,7 @@ export const DefaultValue = ({ mediaRules, relationshipFields, options, + currency, }: DefaultValueProps) => { return ( @@ -69,15 +71,16 @@ export const DefaultValue = ({ variant="body3" color="text.secondary" fontWeight="600" - sx={{ mb: 1, display: "block" }} + sx={{ display: "block" }} > Set a predefined value for this field } /> - - {isDefaultValueEnabled && ( + + {isDefaultValueEnabled && ( + @@ -102,8 +106,8 @@ export const DefaultValue = ({ - )} - + + )} ); }; diff --git a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx index 633776d5a9..91c52679db 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/DefaultValueInput.tsx @@ -65,6 +65,7 @@ type DefaultValueInputProps = { relatedFieldZUID: string; }; options: FieldSettingsOptions[]; + currency?: string; }; export const DefaultValueInput = ({ @@ -75,6 +76,7 @@ export const DefaultValueInput = ({ mediaRules, relationshipFields: { relatedModelZUID, relatedFieldZUID }, options, + currency, }: DefaultValueInputProps) => { const [imageModal, setImageModal] = useState(null); const dispatch = useDispatch(); @@ -573,12 +575,11 @@ export const DefaultValueInput = ({ return ( ); case "date": diff --git a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx index a41e038880..7517f00c6d 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/FieldFormInput.tsx @@ -14,6 +14,10 @@ import { Button, IconButton, Stack, + AutocompleteProps, + InputProps, + OutlinedInputProps, + FilledInputProps, } from "@mui/material"; import { SelectChangeEvent } from "@mui/material/Select"; import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; @@ -25,6 +29,7 @@ import { FormValue } from "./views/FieldForm"; import { FieldSettingsOptions } from "../../../../../../shell/services/types"; import { convertDropdownValue } from "../../utils"; import { withCursorPosition } from "../../../../../../shell/components/withCursorPosition"; +import { Currency } from "../../../../../../shell/components/FieldTypeCurrency/currencies"; const TextFieldWithCursorPosition = withCursorPosition(TextField); @@ -49,7 +54,10 @@ export type FieldNames = | "regexRestrictPattern" | "regexRestrictErrorMessage" | "minValue" - | "maxValue"; + | "maxValue" + | "currency" + | "fileExtensions" + | "fileExtensionsErrorMessage"; type FieldType = | "input" | "checkbox" @@ -78,7 +86,14 @@ export interface DropdownOptions { label: string; value: string; } -interface FieldFormInputProps { +export type AutocompleteConfig = { + inputProps?: + | Partial + | Partial + | Partial; + maxHeight?: number; +}; +type FieldFormInputProps = { fieldConfig: InputField; errorMsg?: string | [string, string][]; onDataChange: ({ @@ -89,9 +104,13 @@ interface FieldFormInputProps { value: FormValue; }) => void; prefillData?: FormValue; - dropdownOptions?: DropdownOptions[]; + dropdownOptions?: DropdownOptions[] | Currency[]; disabled?: boolean; -} + autocompleteConfig?: AutocompleteConfig; +} & Pick< + AutocompleteProps, + "renderOption" | "filterOptions" +>; export const FieldFormInput = ({ fieldConfig, errorMsg, @@ -99,6 +118,9 @@ export const FieldFormInput = ({ prefillData, dropdownOptions, disabled, + renderOption, + filterOptions, + autocompleteConfig, }: FieldFormInputProps) => { const options = fieldConfig.type === "options" || @@ -212,9 +234,19 @@ export const FieldFormInput = ({ {fieldConfig.type === "autocomplete" && ( <> - - {fieldConfig.label} - + + + {fieldConfig.label} + + {fieldConfig.tooltip && ( + + + + )} + )} isOptionEqualToValue={(option, value) => @@ -246,16 +284,22 @@ export const FieldFormInput = ({ height: "40px", }, }} + renderOption={renderOption} + filterOptions={filterOptions} + slotProps={{ + paper: { + sx: { + "& .MuiAutocomplete-listbox": { + maxHeight: autocompleteConfig?.maxHeight || "40vh", + boxSizing: "border-box", + }, + }, + }, + }} /> {prefillData && !dropdownOptions.find((option) => option.value === prefillData) && ( - + {fieldConfig.name === "group_id" && "The folder this was locked to has been deleted"} {fieldConfig.name === "relatedModelZUID" && @@ -324,12 +368,9 @@ export const FieldFormInput = ({ error={Boolean(errorMsg)} helperText={ errorMsg && ( - + {errorMsg} - + ) } type={fieldConfig.inputType || "text"} @@ -445,9 +486,9 @@ const KeyValueInput = ({ handleDataChanged("value", e.target?.value); }} helperText={ - + {labelErrorMsg} - + } error={Boolean(labelErrorMsg)} disabled={disabledFields.includes("value")} @@ -463,9 +504,9 @@ const KeyValueInput = ({ handleDataChanged("key", e.target?.value); }} helperText={ - + {valueErrorMsg} - + } error={Boolean(valueErrorMsg)} disabled={disabledFields.includes("key")} diff --git a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx index 18223ec1a1..101eb794fc 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/MediaRules.tsx @@ -4,14 +4,22 @@ import { Checkbox, Typography, Stack, + InputLabel, + Autocomplete, + TextField, + Chip, } from "@mui/material"; -import { FormValue } from "./views/FieldForm"; +import { Errors, FormValue } from "./views/FieldForm"; import { CustomGroup } from "../hooks/useMediaRules"; - import { InputField } from "./FieldFormInput"; import { FieldFormInput, FieldNames } from "./FieldFormInput"; +import { useEffect, useState } from "react"; + +type MediaFieldName = Extract< + FieldNames, + "limit" | "group_id" | "fileExtensions" +>; -type MediaFieldName = Extract; const MediaLabelsConfig: { [key in MediaFieldName]: { label: string; subLabel: string }; } = { @@ -24,8 +32,83 @@ const MediaLabelsConfig: { label: "Lock to a folder", subLabel: "Ensures files can only be selected from a specific folder", }, + fileExtensions: { + label: "Limit File Types", + subLabel: "Ensures only certain file types can be accepted", + }, }; +const ExtensionPresets = [ + { + label: "Images", + value: [".png", ".jpg", ".jpeg", ".svg", ".gif", ".tif", ".webp"], + }, + { + label: "Videos", + value: [ + ".mob", + ".avi", + ".wmv", + ".mp4", + ".mpeg", + ".mkv", + ".m4v", + ".mpg", + ".webm", + ], + }, + { + label: "Audios", + value: [ + ".mp3", + ".flac", + ".wav", + ".m4a", + ".aac", + ".ape", + ".opus", + ".aiff", + ".aif", + ], + }, + { + label: "Documents", + value: [".doc", ".pdf", ".docx", ".txt", ".rtf", ".odt", ".pages"], + }, + { + label: "Presentations", + value: [ + ".ppt", + ".pptx", + ".key", + ".odp", + ".pps", + ".ppsx", + ".sldx", + ".potx", + ".otp", + ".sxi", + ], + }, + { + label: "Spreadsheets", + value: [ + ".xls", + ".xlsx", + ".csv", + ".tsv", + ".numbers", + ".ods", + ".xlsm", + ".xlsb", + ".xlt", + ".xltx", + ], + }, +] as const; + +const RestrictedExtensions = [".exe", ".dmg"]; + interface Props { fieldConfig: InputField[]; groups: CustomGroup[]; @@ -37,13 +120,90 @@ interface Props { value: FormValue; }) => void; fieldData: { [key: string]: FormValue }; + errors: Errors; } + export const MediaRules = ({ fieldConfig, onDataChange, groups, fieldData, + errors, }: Props) => { + const [inputValue, setInputValue] = useState(""); + const [autoFill, setAutoFill] = useState( + !fieldData.fileExtensionsErrorMessage + ); + const [extensionsError, setExtensionsError] = useState(false); + + useEffect(() => { + if (autoFill) { + onDataChange({ + inputName: "fileExtensionsErrorMessage", + value: + "Only files with the following extensions are allowed: " + + (fieldData["fileExtensions"] as string[])?.join(", "), + }); + } + }, [autoFill, fieldData["fileExtensions"]]); + + const handleInputChange = ( + event: any, + newInputValue: string, + ruleName: string + ) => { + const formattedInput = newInputValue.trim().toLowerCase(); + if (formattedInput && formattedInput[0] !== ".") { + setInputValue(`.${formattedInput}`); + } else { + setInputValue(formattedInput); + } + }; + + const handleKeyDown = (event: any, ruleName: string) => { + if ( + (event.key === "Enter" || event.key === "," || event.key === " ") && + inputValue + ) { + event.preventDefault(); + const newOption = inputValue.toLowerCase().trim(); + if ( + newOption && + !(fieldData[ruleName] as string[]).includes(newOption) && + !RestrictedExtensions.includes(newOption) + ) { + onDataChange({ + inputName: ruleName, + value: [...(fieldData[ruleName] as string[]), newOption], + }); + setInputValue(""); + } + } else if (event.key === "Backspace" && !inputValue) { + const newTags = [...(fieldData[ruleName] as string[])]; + newTags.pop(); + if (!newTags.length) { + setExtensionsError(true); + } + onDataChange({ + inputName: ruleName, + value: newTags, + }); + } + }; + + const handleDelete = (option: string, ruleName: string) => { + const newTags = (fieldData[ruleName] as string[]).filter( + (item) => item.trim() !== option.trim() + ); + if (!newTags.length) { + setExtensionsError(true); + } + onDataChange({ + inputName: ruleName, + value: newTags, + }); + }; + return ( {fieldConfig?.map((rule: InputField, key: number) => { - if (rule.name === "defaultValue") return; + if ( + rule.name === "defaultValue" || + rule.name === "fileExtensionsErrorMessage" + ) + return null; return ( @@ -101,7 +274,9 @@ export const MediaRules = ({ } /> - {Boolean(fieldData[rule.name]) && ( + {Boolean( + fieldData[rule.name] && rule.name !== "fileExtensions" + ) && ( )} + + {Boolean(fieldData[rule.name]) && rule.name === "fileExtensions" && ( + + Extensions * + ( + handleKeyDown(event, rule.name)} + /> + )} + onInputChange={(event, newInputValue) => + handleInputChange(event, newInputValue, rule.name) + } + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + handleDelete(option, rule.name)} + clickable={false} + sx={{ + backgroundColor: "common.white", + borderColor: "grey.300", + borderWidth: 1, + borderStyle: "solid", + }} + /> + )) + } + /> + {errors["fileExtensions"] && extensionsError && ( + + {errors["fileExtensions"]} + + )} + + Add: + {ExtensionPresets.map((preset) => ( + { + const newTags = fieldData[rule.name] as string[]; + const tags = new Set(newTags); + preset.value.forEach((tag) => tags.add(tag)); + onDataChange({ + inputName: rule.name, + value: Array.from(tags), + }); + }} + sx={{ + backgroundColor: "common.white", + borderColor: "grey.300", + borderWidth: 1, + borderStyle: "solid", + }} + /> + ))} + + + Custom Error Message * + + { + setAutoFill(false); + onDataChange({ + inputName: "fileExtensionsErrorMessage", + value: e.target.value, + }); + }} + /> + {errors["fileExtensionsErrorMessage"] && ( + + {errors["fileExtensionsErrorMessage"]} + + )} + + )} ); })} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx index c315404b73..661edcf3ff 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/FieldForm.tsx @@ -13,6 +13,11 @@ import { Button, Grid, Stack, + ListItem, + FilledInputProps, + InputProps, + OutlinedInputProps, + InputAdornment, } from "@mui/material"; import LoadingButton from "@mui/lab/LoadingButton"; import { isEmpty } from "lodash"; @@ -27,7 +32,11 @@ import PauseCircleOutlineRoundedIcon from "@mui/icons-material/PauseCircleOutlin import PlayCircleOutlineRoundedIcon from "@mui/icons-material/PlayCircleOutlineRounded"; import { FieldIcon } from "../../Field/FieldIcon"; -import { FieldFormInput, DropdownOptions } from "../FieldFormInput"; +import { + FieldFormInput, + DropdownOptions, + AutocompleteConfig, +} from "../FieldFormInput"; import { useMediaRules } from "../../hooks/useMediaRules"; import { MediaRules } from "../MediaRules"; import { @@ -64,6 +73,11 @@ import { DefaultValue } from "../DefaultValue"; import { CharacterLimit } from "../CharacterLimit"; import { Rules } from "./Rules"; import { MaxLengths } from "../../../../../../content-editor/src/app/components/Editor/Editor"; +import { + Currency, + currencies, +} from "../../../../../../../shell/components/FieldTypeCurrency/currencies"; +import getFlagEmoji from "../../../../../../../utility/getFlagEmoji"; type ActiveTab = "details" | "rules" | "learn"; type Params = { @@ -223,6 +237,12 @@ export const FieldForm = ({ formFields[field.name] = fieldData.settings[field.name] ?? null; } else if (field.name === "maxValue") { formFields[field.name] = fieldData.settings[field.name] ?? null; + } else if (field.name === "currency") { + formFields[field.name] = fieldData.settings?.currency ?? "USD"; + } else if (field.name === "fileExtensions") { + formFields[field.name] = fieldData.settings[field.name] ?? null; + } else if (field.name === "fileExtensionsErrorMessage") { + formFields[field.name] = fieldData.settings[field.name] ?? null; } else { formFields[field.name] = fieldData[field.name] as FormValue; } @@ -249,7 +269,9 @@ export const FieldForm = ({ field.name === "regexRestrictPattern" || field.name === "regexRestrictErrorMessage" || field.name === "minValue" || - field.name === "maxValue" + field.name === "maxValue" || + field.name === "fileExtensions" || + field.name === "fileExtensionsErrorMessage" ) { formFields[field.name] = null; } else { @@ -393,6 +415,26 @@ export const FieldForm = ({ } } + if (inputName === "currency" && !formData.currency) { + newErrorsObj[inputName] = "Please select a currency"; + } + + if ( + inputName === "fileExtensions" && + formData.fileExtensions !== null && + !(formData.fileExtensions as string[])?.length + ) { + newErrorsObj[inputName] = "This field is required"; + } + + if ( + inputName === "fileExtensionsErrorMessage" && + formData.fileExtensions !== null && + formData.fileExtensionsErrorMessage === "" + ) { + newErrorsObj[inputName] = "This field is required"; + } + if ( inputName in errors && ![ @@ -405,6 +447,9 @@ export const FieldForm = ({ "regexRestrictErrorMessage", "minValue", "maxValue", + "currency", + "fileExtensions", + "fileExtensionsErrorMessage", ].includes(inputName) ) { const { maxLength, label, validate } = FORM_CONFIG[type].details.find( @@ -508,7 +553,9 @@ export const FieldForm = ({ errors.regexRestrictPattern || errors.regexRestrictErrorMessage || errors.minValue || - errors.maxValue + errors.maxValue || + errors.fileExtensions || + errors.fileExtensionsErrorMessage ) { setActiveTab("rules"); } else { @@ -562,6 +609,16 @@ export const FieldForm = ({ ...(formData.maxValue !== null && { maxValue: formData.maxValue as number, }), + ...(formData.currency !== null && { + currency: formData.currency as string, + }), + ...(formData.fileExtensions && { + fileExtensions: formData.fileExtensions as string[], + }), + ...(formData.fileExtensionsErrorMessage && { + fileExtensionsErrorMessage: + formData.fileExtensionsErrorMessage as string, + }), }, sort: isUpdateField ? fieldData.sort : sort, // Just use the length since sort starts at 0 }; @@ -757,6 +814,9 @@ export const FieldForm = ({ let dropdownOptions: DropdownOptions[]; let disabled = false; + let renderOption: any; + let filterOptions: any; + let autocompleteConfig: AutocompleteConfig = {}; if (fieldConfig.name === "relatedModelZUID") { dropdownOptions = modelsOptions; @@ -768,6 +828,74 @@ export const FieldForm = ({ disabled = isFetchingSelectedModelFields; } + if (fieldConfig.name === "currency") { + const selectedValue = currencies.find( + (currency) => currency.value === formData.currency + ); + dropdownOptions = currencies; + renderOption = (props: any, value: Currency) => ( + + + + {value.value} {value.symbol_native}   + + {value.label} + + ); + filterOptions = (options: Currency[], state: any) => { + if (state.inputValue) { + return options.filter( + (option) => + option.label + ?.toLowerCase() + .includes(state.inputValue.toLowerCase()) || + option.value + ?.toLowerCase() + .includes(state.inputValue.toLowerCase()) + ); + } else { + return options; + } + }; + autocompleteConfig.inputProps = { + startAdornment: !!selectedValue && ( + + + + {selectedValue.value} {selectedValue.symbol_native} + + + ), + }; + autocompleteConfig.maxHeight = 256; + } + return ( ); })} diff --git a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx index 8c7d473707..a92b5caf27 100644 --- a/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx +++ b/src/apps/schema/src/app/components/AddFieldModal/views/Rules.tsx @@ -48,18 +48,6 @@ export const Rules = ({ return ( - {type === "images" && ( - - )} - + {type === "images" && ( + + )} + {(type === "text" || type === "textarea") && ( <> { + const { contentModelZUID } = useParams<{ contentModelZUID: string }>(); + const { data: modelData } = useGetContentModelQuery(contentModelZUID, { + skip: !contentModelZUID, + }); + + const filteredApiTypes = useMemo(() => { + if (modelData?.type === "dataset") { + return apiTypes.filter((apiType) => apiType !== "site-generators"); + } + + return apiTypes; + }, [modelData]); + return ( { height: "100%", }} > - {apiTypes.map((apiType) => ( + {filteredApiTypes?.map((apiType) => ( ))} diff --git a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx index 0e14b4327a..89822d8800 100644 --- a/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx +++ b/src/apps/schema/src/app/components/ModelApi/ApiDetails.tsx @@ -8,7 +8,7 @@ import { Stack, CircularProgress, } from "@mui/material"; -import { useHistory, useLocation } from "react-router"; +import { useHistory, useLocation, useParams } from "react-router"; import { SvgIconComponent } from "@mui/icons-material"; import ArrowBackRoundedIcon from "@mui/icons-material/ArrowBackRounded"; import BoltRoundedIcon from "@mui/icons-material/BoltRounded"; @@ -24,8 +24,12 @@ import { useSelector } from "react-redux"; import { AppState } from "../../../../../../shell/store/types"; import { ApiDomainEndpoints } from "./ApiDomainEndpoints"; import CodeRoundedIcon from "@mui/icons-material/CodeRounded"; -import { useGetInstanceSettingsQuery } from "../../../../../../shell/services/instance"; +import { + useGetContentModelQuery, + useGetInstanceSettingsQuery, +} from "../../../../../../shell/services/instance"; import { HeadlessSwitcher } from "./HeadlessSwitcher"; +import { useEffect, useMemo } from "react"; const apiTypeIconMap: Record = { "quick-access": BoltRoundedIcon, @@ -46,6 +50,10 @@ const apiTypesWithEndpoints = [ export const ApiDetails = () => { const history = useHistory(); const location = useLocation(); + const { contentModelZUID } = useParams<{ contentModelZUID: string }>(); + const { data: modelData } = useGetContentModelQuery(contentModelZUID, { + skip: !contentModelZUID, + }); const installedApps = useSelector((state: AppState) => state.apps.installed); const selectedType = location.pathname.split("/").pop() as ApiType; const { data: instanceSettings, isFetching } = useGetInstanceSettingsQuery( @@ -56,6 +64,14 @@ export const ApiDetails = () => { instanceSettings?.find((setting) => setting.key === "mode")?.value !== "traditional"; + const filteredApiTypes = useMemo(() => { + if (modelData?.type === "dataset") { + return apiTypes.filter((apiType) => apiType !== "site-generators"); + } + + return apiTypes; + }, [modelData]); + const handleVisualLayoutClick = () => { const layoutApp = installedApps?.find( (app: any) => app?.name === "layouts" @@ -67,6 +83,12 @@ export const ApiDetails = () => { } }; + useEffect(() => { + if (selectedType === "site-generators" && modelData?.type === "dataset") { + history.replace(`${location.pathname.split("/").slice(0, -1).join("/")}`); + } + }, [selectedType, modelData]); + return ( { - {apiTypes.map((type) => ( + {filteredApiTypes?.map((type) => ( = { rules: [...COMMON_RULES], }, currency: { - details: [...COMMON_FIELDS], + details: [ + { + name: "currency", + type: "autocomplete", + label: "Currency", + required: true, + gridSize: 12, + tooltip: + "The selected currency code, symbol, and flag will be displayed for this field in the content item and can be accessed through the field settings via the API.", + placeholder: "Select a Currency", + autoFocus: true, + }, + { + ...COMMON_FIELDS[0], + autoFocus: false, + }, + ...COMMON_FIELDS.slice(1), + ], rules: [...COMMON_RULES, ...INPUT_RANGE_RULES], }, date: { @@ -556,6 +573,20 @@ const FORM_CONFIG: Record = { required: false, gridSize: 12, }, + { + name: "fileExtensions", + type: "input", + label: "File Extensions", + required: false, + gridSize: 12, + }, + { + name: "fileExtensionsErrorMessage", + type: "input", + label: "File extensions error message", + required: false, + gridSize: 12, + }, ...COMMON_RULES, ], }, diff --git a/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js b/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js index 2b5e624c94..50b0fc2646 100644 --- a/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js +++ b/src/apps/seo/src/views/RedirectsManager/RedirectsTable/RedirectCreator/RedirectCreator.js @@ -48,7 +48,6 @@ export function RedirectCreator(props) { ) .then(() => { setFrom(""); - setTo(""); setContentSearchValue(""); }); }; @@ -123,7 +122,7 @@ export function RedirectCreator(props) { variant="outlined" size="small" fullWidth - defaultValue={to} + value={to} InputProps={{ startAdornment: ( diff --git a/src/shell/app.config.js b/src/shell/app.config.js index 2a15352d20..ab721713b8 100644 --- a/src/shell/app.config.js +++ b/src/shell/app.config.js @@ -8,6 +8,7 @@ module.exports = { API_METRICS: "https://metrics.api.zesty.io", API_INSTANCE: ".api.zesty.io/v1", API_INSTANCE_PROTOCOL: "https://", + API_ANALYTICS: "https://analytics.api.zesty.io", CLOUD_FUNCTIONS_DOMAIN: "https://us-central1-zesty-prod.cloudfunctions.net", @@ -56,6 +57,7 @@ module.exports = { API_METRICS: "https://metrics.api.stage.zesty.io", API_INSTANCE: ".api.stage.zesty.io/v1", API_INSTANCE_PROTOCOL: "https://", + API_ANALYTICS: "https://analytics.api.stage.zesty.io", CLOUD_FUNCTIONS_DOMAIN: "https://us-central1-zesty-stage.cloudfunctions.net", @@ -80,7 +82,7 @@ module.exports = { URL_ACCOUNTS: "https://accounts.stage.zesty.io", URL_MARKETPLACE: "https://kfg6bckb-dev.webengine.zesty.io/marketplace/apps/", - URL_APPS: "https://apps.stage.zesty.io", + URL_APPS: "https://apps-beta.zesty.io", COOKIE_NAME: "STAGE_APP_SID", COOKIE_DOMAIN: ".zesty.io", @@ -101,6 +103,7 @@ module.exports = { API_METRICS: "https://metrics.api.dev.zesty.io", API_INSTANCE: ".api.dev.zesty.io/v1", API_INSTANCE_PROTOCOL: "https://", + API_ANALYTICS: "https://analytics-api-m3rbwjxm5q-uc.a.run.app", CLOUD_FUNCTIONS_DOMAIN: "https://us-central1-zesty-dev.cloudfunctions.net", @@ -129,7 +132,7 @@ module.exports = { URL_ACCOUNTS: "https://zesty.io", URL_MARKETPLACE: "https://kfg6bckb-dev.webengine.zesty.io/marketplace/apps/", - URL_APPS: "https://apps.dev.zesty.io", + URL_APPS: "https://apps-beta.zesty.io", COOKIE_NAME: "DEV_APP_SID", COOKIE_DOMAIN: ".zesty.io", @@ -147,6 +150,7 @@ module.exports = { API_ACCOUNTS: "//accounts.api.zesty.localdev:3022/v1", API_INSTANCE: ".api.zesty.localdev:3023/v1", API_INSTANCE_PROTOCOL: "http://", + API_ANALYTICS: "https://analytics-api-m3rbwjxm5q-uc.a.run.app", SERVICE_AUTH: "http://auth.api.zesty.localdev:3011", SERVICE_EMAIL: "", diff --git a/src/shell/components/CascadingMenuItem/index.tsx b/src/shell/components/CascadingMenuItem/index.tsx index b929507ad7..abd0bdce44 100644 --- a/src/shell/components/CascadingMenuItem/index.tsx +++ b/src/shell/components/CascadingMenuItem/index.tsx @@ -1,4 +1,4 @@ -import React, { FC, useState } from "react"; +import React, { FC, useEffect, useState } from "react"; import { MenuItem, MenuItemProps, @@ -23,11 +23,37 @@ export const CascadingMenuItem: FC = ({ ...props }) => { const [anchorEl, setAnchorEl] = useState(null); + const [isChildHovered, setIsChildHovered] = useState(false); + const [isParentHovered, setIsParentHovered] = useState(false); + + /** Note: This essentially adds a small delay to allow a user to move their mouse + * to the child component instead of just immediately closing it outright + */ + useEffect(() => { + let timeoutId: NodeJS.Timeout; + + if (!isParentHovered) { + timeoutId = setTimeout(() => { + if (!isChildHovered) { + setAnchorEl(null); + } + }, 100); + } + + return () => { + clearTimeout(timeoutId); + }; + }, [isParentHovered, isChildHovered]); return ( setAnchorEl(evt.currentTarget)} - onMouseLeave={() => setAnchorEl(null)} + onMouseEnter={(evt) => { + setAnchorEl(evt.currentTarget); + setIsParentHovered(true); + }} + onMouseLeave={() => { + setIsParentHovered(false); + }} sx={{ // HACK: Prevents the menu item to be in active style state when the sub-menu is opened. "&.MuiMenuItem-root": { @@ -51,7 +77,17 @@ export const CascadingMenuItem: FC = ({ }} {...PopperProps} > - + { + setIsChildHovered(true); + }} + onMouseLeave={() => { + setIsChildHovered(false); + setAnchorEl(null); + }} + > {children} diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js deleted file mode 100644 index 19ccb43fea..0000000000 --- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.js +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useState } from "react"; - -import { Input } from "@zesty-io/core/Input"; -import { Select, Option } from "@zesty-io/core/Select"; -import { currencies } from "./currencies"; - -import styles from "./FieldTypeCurrency.less"; -export const FieldTypeCurrency = React.memo(function FieldTypeCurrency(props) { - // console.log("FieldTypeCurrency:render"); - - const [monetaryValue, setMonetaryValue] = useState(props.value || "0.00"); - const [currency, setCurrency] = useState( - (props.code && currencies[props.code]) || currencies["USD"] - ); - - return ( - - ); -}); diff --git a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less b/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less deleted file mode 100644 index 03c4c02fe0..0000000000 --- a/src/shell/components/FieldTypeCurrency/FieldTypeCurrency.less +++ /dev/null @@ -1,52 +0,0 @@ -@import "~@zesty-io/core/colors.less"; -@import "~@zesty-io/core/typography.less"; - -.FieldTypeCurrency { - display: flex; - flex-direction: column; - max-width: 300px; - - .FieldTypeCurrencyLabel { - display: flex; - justify-content: space-between; - margin-bottom: 3px; - font-size: @font-size-label; - span { - display: flex; - } - } - - .CurrencyFields { - display: flex; - .SelectCurrency { - width: 100px; - span > span { - background: @zesty-tab-blue; - color: @white; - border: @zesty-tab-blue; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - } - - // &:active, - // &:focus, - // &:hover { - // span > span { - // color: #5b667d; - // } - // } - - ul { - left: 0; - min-width: 320px; - } - } - .CurrencyInput { - width: 100%; - border-radius: 0px 8px 8px 0px; - } - } -} -.CurrencyInput { - border: 1px solid #f2f4f7; -} diff --git a/src/shell/components/FieldTypeCurrency/currencies.js b/src/shell/components/FieldTypeCurrency/currencies.ts similarity index 62% rename from src/shell/components/FieldTypeCurrency/currencies.js rename to src/shell/components/FieldTypeCurrency/currencies.ts index 10102d6fa9..ab439b5265 100644 --- a/src/shell/components/FieldTypeCurrency/currencies.js +++ b/src/shell/components/FieldTypeCurrency/currencies.ts @@ -1,1064 +1,1193 @@ -export const currencies = { - USD: { - symbol: " $ ", - name: "US Dollar", - symbol_native: "$", - decimal_digits: 2, - rounding: 0, - code: "USD", - name_plural: "US dollars", - }, - CAD: { - symbol: "CA$ ", - name: "Canadian Dollar", - symbol_native: "$", - decimal_digits: 2, - rounding: 0, - code: "CAD", - name_plural: "Canadian dollars", - }, - EUR: { - symbol: " € ", - name: "Euro", - symbol_native: "€", - decimal_digits: 2, - rounding: 0, - code: "EUR", - name_plural: "euros", - }, - AED: { - symbol: "AED", - name: "United Arab Emirates Dirham", - symbol_native: "د.إ.‏", - decimal_digits: 2, - rounding: 0, - code: "AED", - name_plural: "UAE dirhams", - }, - AFN: { +export type Currency = { + symbol: string; + label: string; + symbol_native: string; + decimal_digits: number; + rounding: number; + value: string; + name_plural: string; + countryCode: string; +}; + +export const currencies: Currency[] = [ + { symbol: "Af ", - name: "Afghan Afghani", + label: "Afghan Afghani", symbol_native: "؋", decimal_digits: 0, rounding: 0, - code: "AFN", + value: "AFN", name_plural: "Afghan Afghanis", + countryCode: "AF", }, - ALL: { + { symbol: "ALL", - name: "Albanian Lek", + label: "Albanian Lek", symbol_native: "Lek", decimal_digits: 0, rounding: 0, - code: "ALL", + value: "ALL", name_plural: "Albanian lekë", + countryCode: "AL", }, - AMD: { - symbol: "AMD", - name: "Armenian Dram", - symbol_native: "դր.", - decimal_digits: 0, + { + symbol: "DA", + label: "Algerian Dinar", + symbol_native: "د.ج.‏", + decimal_digits: 2, rounding: 0, - code: "AMD", - name_plural: "Armenian drams", + value: "DZD", + name_plural: "Algerian dinars", + countryCode: "DZ", }, - ARS: { + { symbol: "AR$", - name: "Argentine Peso", + label: "Argentine Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "ARS", + value: "ARS", name_plural: "Argentine pesos", + countryCode: "AR", }, - AUD: { + { + symbol: "AMD", + label: "Armenian Dram", + symbol_native: "դր.", + decimal_digits: 0, + rounding: 0, + value: "AMD", + name_plural: "Armenian drams", + countryCode: "AM", + }, + { symbol: "AU$", - name: "Australian Dollar", + label: "Australian Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "AUD", + value: "AUD", name_plural: "Australian dollars", + countryCode: "AU", }, - AZN: { + { symbol: "man.", - name: "Azerbaijani Manat", + label: "Azerbaijani Manat", symbol_native: "ман.", decimal_digits: 2, rounding: 0, - code: "AZN", + value: "AZN", name_plural: "Azerbaijani manats", + countryCode: "AZ", }, - BAM: { - symbol: "KM", - name: "Bosnia-Herzegovina Convertible Mark", - symbol_native: "KM", - decimal_digits: 2, + { + symbol: "BD", + label: "Bahraini Dinar", + symbol_native: "د.ب.‏", + decimal_digits: 3, rounding: 0, - code: "BAM", - name_plural: "Bosnia-Herzegovina convertible marks", + value: "BHD", + name_plural: "Bahraini dinars", + countryCode: "BH", }, - BDT: { + { symbol: "Tk", - name: "Bangladeshi Taka", + label: "Bangladeshi Taka", symbol_native: "৳", decimal_digits: 2, rounding: 0, - code: "BDT", + value: "BDT", name_plural: "Bangladeshi takas", + countryCode: "BD", }, - BGN: { - symbol: "BGN", - name: "Bulgarian Lev", - symbol_native: "лв.", - decimal_digits: 2, - rounding: 0, - code: "BGN", - name_plural: "Bulgarian leva", - }, - BHD: { - symbol: "BD", - name: "Bahraini Dinar", - symbol_native: "د.ب.‏", - decimal_digits: 3, - rounding: 0, - code: "BHD", - name_plural: "Bahraini dinars", - }, - BIF: { - symbol: "FBu", - name: "Burundian Franc", - symbol_native: "FBu", + { + symbol: "BYR", + label: "Belarusian Ruble", + symbol_native: "BYR", decimal_digits: 0, rounding: 0, - code: "BIF", - name_plural: "Burundian francs", + value: "BYR", + name_plural: "Belarusian rubles", + countryCode: "BY", }, - BND: { - symbol: "BN$", - name: "Brunei Dollar", + { + symbol: "BZ$", + label: "Belize Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "BND", - name_plural: "Brunei dollars", + value: "BZD", + name_plural: "Belize dollars", + countryCode: "BZ", }, - BOB: { + { symbol: "Bs", - name: "Bolivian Boliviano", + label: "Bolivian Boliviano", symbol_native: "Bs", decimal_digits: 2, rounding: 0, - code: "BOB", + value: "BOB", name_plural: "Bolivian bolivianos", + countryCode: "BO", }, - BRL: { - symbol: "R$", - name: "Brazilian Real", - symbol_native: "R$", + { + symbol: "KM", + label: "Bosnia-Herzegovina Convertible Mark", + symbol_native: "KM", decimal_digits: 2, rounding: 0, - code: "BRL", - name_plural: "Brazilian reals", + value: "BAM", + name_plural: "Bosnia-Herzegovina convertible marks", + countryCode: "BA", }, - BWP: { + { symbol: "BWP", - name: "Botswanan Pula", + label: "Botswanan Pula", symbol_native: "P", decimal_digits: 2, rounding: 0, - code: "BWP", + value: "BWP", name_plural: "Botswanan pulas", + countryCode: "BW", }, - BYR: { - symbol: "BYR", - name: "Belarusian Ruble", - symbol_native: "BYR", - decimal_digits: 0, + { + symbol: "R$", + label: "Brazilian Real", + symbol_native: "R$", + decimal_digits: 2, rounding: 0, - code: "BYR", - name_plural: "Belarusian rubles", + value: "BRL", + name_plural: "Brazilian reals", + countryCode: "BR", }, - BZD: { - symbol: "BZ$", - name: "Belize Dollar", + { + symbol: "£", + label: "British Pound Sterling", + symbol_native: "£", + decimal_digits: 2, + rounding: 0, + value: "GBP", + name_plural: "British pounds sterling", + countryCode: "GB", + }, + { + symbol: "BN$", + label: "Brunei Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "BZD", - name_plural: "Belize dollars", + value: "BND", + name_plural: "Brunei dollars", + countryCode: "BN", }, - CDF: { - symbol: "CDF", - name: "Congolese Franc", - symbol_native: "FrCD", + { + symbol: "BGN", + label: "Bulgarian Lev", + symbol_native: "лв.", decimal_digits: 2, rounding: 0, - code: "CDF", - name_plural: "Congolese francs", + value: "BGN", + name_plural: "Bulgarian leva", + countryCode: "BG", }, - CHF: { - symbol: "CHF", - name: "Swiss Franc", - symbol_native: "CHF", + { + symbol: "FBu", + label: "Burundian Franc", + symbol_native: "FBu", + decimal_digits: 0, + rounding: 0, + value: "BIF", + name_plural: "Burundian francs", + countryCode: "BI", + }, + { + symbol: "KHR", + label: "Cambodian Riel", + symbol_native: "៛", decimal_digits: 2, - rounding: 0.05, - code: "CHF", - name_plural: "Swiss francs", + rounding: 0, + value: "KHR", + name_plural: "Cambodian riels", + countryCode: "KH", + }, + { + symbol: "CA$ ", + label: "Canadian Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + value: "CAD", + name_plural: "Canadian dollars", + countryCode: "CA", + }, + { + symbol: "CV$", + label: "Cape Verdean Escudo", + symbol_native: "CV$", + decimal_digits: 2, + rounding: 0, + value: "CVE", + name_plural: "Cape Verdean escudos", + countryCode: "CV", }, - CLP: { + { + symbol: "CFA", + label: "CFA Franc BCEAO", + symbol_native: "CFA", + decimal_digits: 0, + rounding: 0, + value: "XOF", + name_plural: "CFA francs BCEAO", + countryCode: "CI", + }, + { + symbol: "FCFA", + label: "CFA Franc BEAC", + symbol_native: "FCFA", + decimal_digits: 0, + rounding: 0, + value: "XAF", + name_plural: "CFA francs BEAC", + countryCode: "CF", + }, + { symbol: "CL$", - name: "Chilean Peso", + label: "Chilean Peso", symbol_native: "$", decimal_digits: 0, rounding: 0, - code: "CLP", + value: "CLP", name_plural: "Chilean pesos", + countryCode: "CL", }, - CNY: { + { symbol: "CN¥", - name: "Chinese Yuan", + label: "Chinese Yuan", symbol_native: "CN¥", decimal_digits: 2, rounding: 0, - code: "CNY", + value: "CNY", name_plural: "Chinese yuan", + countryCode: "CN", }, - COP: { + { symbol: "CO$", - name: "Colombian Peso", + label: "Colombian Peso", symbol_native: "$", decimal_digits: 0, rounding: 0, - code: "COP", + value: "COP", name_plural: "Colombian pesos", + countryCode: "CO", + }, + { + symbol: "CF", + label: "Comorian Franc", + symbol_native: "FC", + decimal_digits: 0, + rounding: 0, + value: "KMF", + name_plural: "Comorian francs", + countryCode: "KM", }, - CRC: { + { + symbol: "CDF", + label: "Congolese Franc", + symbol_native: "FrCD", + decimal_digits: 2, + rounding: 0, + value: "CDF", + name_plural: "Congolese francs", + countryCode: "CD", + }, + { symbol: "₡", - name: "Costa Rican Colón", + label: "Costa Rican Colón", symbol_native: "₡", decimal_digits: 0, rounding: 0, - code: "CRC", + value: "CRC", name_plural: "Costa Rican colóns", + countryCode: "CR", }, - CVE: { - symbol: "CV$", - name: "Cape Verdean Escudo", - symbol_native: "CV$", + { + symbol: "kn", + label: "Croatian Kuna", + symbol_native: "kn", decimal_digits: 2, rounding: 0, - code: "CVE", - name_plural: "Cape Verdean escudos", + value: "HRK", + name_plural: "Croatian kunas", + countryCode: "HR", }, - CZK: { + { symbol: "Kč", - name: "Czech Republic Koruna", + label: "Czech Republic Koruna", symbol_native: "Kč", decimal_digits: 2, rounding: 0, - code: "CZK", + value: "CZK", name_plural: "Czech Republic korunas", + countryCode: "CZ", }, - DJF: { - symbol: "Fdj", - name: "Djiboutian Franc", - symbol_native: "Fdj", - decimal_digits: 0, - rounding: 0, - code: "DJF", - name_plural: "Djiboutian francs", - }, - DKK: { + { symbol: "Dkr", - name: "Danish Krone", + label: "Danish Krone", symbol_native: "kr", decimal_digits: 2, rounding: 0, - code: "DKK", + value: "DKK", name_plural: "Danish kroner", + countryCode: "DK", }, - DOP: { + { + symbol: "Fdj", + label: "Djiboutian Franc", + symbol_native: "Fdj", + decimal_digits: 0, + rounding: 0, + value: "DJF", + name_plural: "Djiboutian francs", + countryCode: "DJ", + }, + { symbol: "RD$", - name: "Dominican Peso", + label: "Dominican Peso", symbol_native: "RD$", decimal_digits: 2, rounding: 0, - code: "DOP", + value: "DOP", name_plural: "Dominican pesos", + countryCode: "DO", }, - DZD: { - symbol: "DA", - name: "Algerian Dinar", - symbol_native: "د.ج.‏", - decimal_digits: 2, - rounding: 0, - code: "DZD", - name_plural: "Algerian dinars", - }, - EEK: { - symbol: "Ekr", - name: "Estonian Kroon", - symbol_native: "kr", - decimal_digits: 2, - rounding: 0, - code: "EEK", - name_plural: "Estonian kroons", - }, - EGP: { + { symbol: "EGP", - name: "Egyptian Pound", + label: "Egyptian Pound", symbol_native: "ج.م.‏", decimal_digits: 2, rounding: 0, - code: "EGP", + value: "EGP", name_plural: "Egyptian pounds", + countryCode: "EG", }, - ERN: { + { symbol: "Nfk", - name: "Eritrean Nakfa", + label: "Eritrean Nakfa", symbol_native: "Nfk", decimal_digits: 2, rounding: 0, - code: "ERN", + value: "ERN", name_plural: "Eritrean nakfas", + countryCode: "ER", }, - ETB: { + { + symbol: "Ekr", + label: "Estonian Kroon", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + value: "EEK", + name_plural: "Estonian kroons", + countryCode: "EE", + }, + { symbol: "Br", - name: "Ethiopian Birr", + label: "Ethiopian Birr", symbol_native: "Br", decimal_digits: 2, rounding: 0, - code: "ETB", + value: "ETB", name_plural: "Ethiopian birrs", + countryCode: "ET", }, - GBP: { - symbol: "£", - name: "British Pound Sterling", - symbol_native: "£", + { + symbol: " € ", + label: "Euro", + symbol_native: "€", decimal_digits: 2, rounding: 0, - code: "GBP", - name_plural: "British pounds sterling", + value: "EUR", + name_plural: "euros", + countryCode: "EU", }, - GEL: { + { symbol: "GEL", - name: "Georgian Lari", + label: "Georgian Lari", symbol_native: "GEL", decimal_digits: 2, rounding: 0, - code: "GEL", + value: "GEL", name_plural: "Georgian laris", + countryCode: "GE", }, - GHS: { + { symbol: "GH₵", - name: "Ghanaian Cedi", + label: "Ghanaian Cedi", symbol_native: "GH₵", decimal_digits: 2, rounding: 0, - code: "GHS", + value: "GHS", name_plural: "Ghanaian cedis", + countryCode: "GH", }, - GNF: { - symbol: "FG", - name: "Guinean Franc", - symbol_native: "FG", - decimal_digits: 0, - rounding: 0, - code: "GNF", - name_plural: "Guinean francs", - }, - GTQ: { + { symbol: "GTQ", - name: "Guatemalan Quetzal", + label: "Guatemalan Quetzal", symbol_native: "Q", decimal_digits: 2, rounding: 0, - code: "GTQ", + value: "GTQ", name_plural: "Guatemalan quetzals", + countryCode: "GT", }, - HKD: { - symbol: "HK$", - name: "Hong Kong Dollar", - symbol_native: "$", - decimal_digits: 2, + { + symbol: "FG", + label: "Guinean Franc", + symbol_native: "FG", + decimal_digits: 0, rounding: 0, - code: "HKD", - name_plural: "Hong Kong dollars", + value: "GNF", + name_plural: "Guinean francs", + countryCode: "GN", }, - HNL: { + { symbol: "HNL", - name: "Honduran Lempira", + label: "Honduran Lempira", symbol_native: "L", decimal_digits: 2, rounding: 0, - code: "HNL", + value: "HNL", name_plural: "Honduran lempiras", + countryCode: "HN", }, - HRK: { - symbol: "kn", - name: "Croatian Kuna", - symbol_native: "kn", + { + symbol: "HK$", + label: "Hong Kong Dollar", + symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "HRK", - name_plural: "Croatian kunas", + value: "HKD", + name_plural: "Hong Kong dollars", + countryCode: "HK", }, - HUF: { + { symbol: "Ft", - name: "Hungarian Forint", + label: "Hungarian Forint", symbol_native: "Ft", decimal_digits: 0, rounding: 0, - code: "HUF", + value: "HUF", name_plural: "Hungarian forints", + countryCode: "HU", }, - IDR: { - symbol: "Rp", - name: "Indonesian Rupiah", - symbol_native: "Rp", + { + symbol: "Ikr", + label: "Icelandic Króna", + symbol_native: "kr", decimal_digits: 0, rounding: 0, - code: "IDR", - name_plural: "Indonesian rupiahs", - }, - ILS: { - symbol: "₪", - name: "Israeli New Sheqel", - symbol_native: "₪", - decimal_digits: 2, - rounding: 0, - code: "ILS", - name_plural: "Israeli new sheqels", + value: "ISK", + name_plural: "Icelandic krónur", + countryCode: "IS", }, - INR: { + { symbol: "Rs", - name: "Indian Rupee", - symbol_native: "টকা", + label: "Indian Rupee", + symbol_native: "₹", decimal_digits: 2, rounding: 0, - code: "INR", + value: "INR", name_plural: "Indian rupees", + countryCode: "IN", }, - IQD: { - symbol: "IQD", - name: "Iraqi Dinar", - symbol_native: "د.ع.‏", + { + symbol: "Rp", + label: "Indonesian Rupiah", + symbol_native: "Rp", decimal_digits: 0, rounding: 0, - code: "IQD", - name_plural: "Iraqi dinars", + value: "IDR", + name_plural: "Indonesian rupiahs", + countryCode: "ID", }, - IRR: { + { symbol: "IRR", - name: "Iranian Rial", + label: "Iranian Rial", symbol_native: "﷼", decimal_digits: 0, rounding: 0, - code: "IRR", + value: "IRR", name_plural: "Iranian rials", + countryCode: "IR", }, - ISK: { - symbol: "Ikr", - name: "Icelandic Króna", - symbol_native: "kr", + { + symbol: "IQD", + label: "Iraqi Dinar", + symbol_native: "د.ع.‏", decimal_digits: 0, rounding: 0, - code: "ISK", - name_plural: "Icelandic krónur", + value: "IQD", + name_plural: "Iraqi dinars", + countryCode: "IQ", + }, + { + symbol: "₪", + label: "Israeli New Sheqel", + symbol_native: "₪", + decimal_digits: 2, + rounding: 0, + value: "ILS", + name_plural: "Israeli new sheqels", + countryCode: "IL", }, - JMD: { + { symbol: "J$", - name: "Jamaican Dollar", + label: "Jamaican Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "JMD", + value: "JMD", name_plural: "Jamaican dollars", + countryCode: "JM", }, - JOD: { - symbol: "JD", - name: "Jordanian Dinar", - symbol_native: "د.أ.‏", - decimal_digits: 3, - rounding: 0, - code: "JOD", - name_plural: "Jordanian dinars", - }, - JPY: { + { symbol: "¥", - name: "Japanese Yen", + label: "Japanese Yen", symbol_native: "¥", decimal_digits: 0, rounding: 0, - code: "JPY", + value: "JPY", name_plural: "Japanese yen", + countryCode: "JP", }, - KES: { - symbol: "Ksh", - name: "Kenyan Shilling", - symbol_native: "Ksh", - decimal_digits: 2, - rounding: 0, - code: "KES", - name_plural: "Kenyan shillings", - }, - KHR: { - symbol: "KHR", - name: "Cambodian Riel", - symbol_native: "៛", - decimal_digits: 2, - rounding: 0, - code: "KHR", - name_plural: "Cambodian riels", - }, - KMF: { - symbol: "CF", - name: "Comorian Franc", - symbol_native: "FC", - decimal_digits: 0, - rounding: 0, - code: "KMF", - name_plural: "Comorian francs", - }, - KRW: { - symbol: "₩", - name: "South Korean Won", - symbol_native: "₩", - decimal_digits: 0, - rounding: 0, - code: "KRW", - name_plural: "South Korean won", - }, - KWD: { - symbol: "KD", - name: "Kuwaiti Dinar", - symbol_native: "د.ك.‏", + { + symbol: "JD", + label: "Jordanian Dinar", + symbol_native: "د.أ.‏", decimal_digits: 3, rounding: 0, - code: "KWD", - name_plural: "Kuwaiti dinars", + value: "JOD", + name_plural: "Jordanian dinars", + countryCode: "JO", }, - KZT: { + { symbol: "KZT", - name: "Kazakhstani Tenge", + label: "Kazakhstani Tenge", symbol_native: "тңг.", - decimal_digits: 2, - rounding: 0, - code: "KZT", - name_plural: "Kazakhstani tenges", - }, - LBP: { - symbol: "LB£", - name: "Lebanese Pound", - symbol_native: "ل.ل.‏", - decimal_digits: 0, + decimal_digits: 2, rounding: 0, - code: "LBP", - name_plural: "Lebanese pounds", + value: "KZT", + name_plural: "Kazakhstani tenges", + countryCode: "KZ", }, - LKR: { - symbol: "SLRs", - name: "Sri Lankan Rupee", - symbol_native: "SL Re", + { + symbol: "Ksh", + label: "Kenyan Shilling", + symbol_native: "Ksh", decimal_digits: 2, rounding: 0, - code: "LKR", - name_plural: "Sri Lankan rupees", + value: "KES", + name_plural: "Kenyan shillings", + countryCode: "KE", }, - LTL: { - symbol: "Lt", - name: "Lithuanian Litas", - symbol_native: "Lt", - decimal_digits: 2, + { + symbol: "KD", + label: "Kuwaiti Dinar", + symbol_native: "د.ك.‏", + decimal_digits: 3, rounding: 0, - code: "LTL", - name_plural: "Lithuanian litai", + value: "KWD", + name_plural: "Kuwaiti dinars", + countryCode: "KW", }, - LVL: { + { symbol: "Ls", - name: "Latvian Lats", + label: "Latvian Lats", symbol_native: "Ls", decimal_digits: 2, rounding: 0, - code: "LVL", + value: "LVL", name_plural: "Latvian lati", + countryCode: "LV", + }, + { + symbol: "LB£", + label: "Lebanese Pound", + symbol_native: "ل.ل.‏", + decimal_digits: 0, + rounding: 0, + value: "LBP", + name_plural: "Lebanese pounds", + countryCode: "LB", }, - LYD: { + { symbol: "LD", - name: "Libyan Dinar", + label: "Libyan Dinar", symbol_native: "د.ل.‏", decimal_digits: 3, rounding: 0, - code: "LYD", + value: "LYD", name_plural: "Libyan dinars", + countryCode: "LY", }, - MAD: { - symbol: "MAD", - name: "Moroccan Dirham", - symbol_native: "د.م.‏", + { + symbol: "Lt", + label: "Lithuanian Litas", + symbol_native: "Lt", decimal_digits: 2, rounding: 0, - code: "MAD", - name_plural: "Moroccan dirhams", + value: "LTL", + name_plural: "Lithuanian litai", + countryCode: "LT", }, - MDL: { - symbol: "MDL", - name: "Moldovan Leu", - symbol_native: "MDL", + { + symbol: "MOP$", + label: "Macanese Pataca", + symbol_native: "MOP$", decimal_digits: 2, rounding: 0, - code: "MDL", - name_plural: "Moldovan lei", - }, - MGA: { - symbol: "MGA", - name: "Malagasy Ariary", - symbol_native: "MGA", - decimal_digits: 0, - rounding: 0, - code: "MGA", - name_plural: "Malagasy Ariaries", + value: "MOP", + name_plural: "Macanese patacas", + countryCode: "MO", }, - MKD: { + { symbol: "MKD", - name: "Macedonian Denar", - symbol_native: "MKD", + label: "Macedonian Denar", + symbol_native: "ден", decimal_digits: 2, rounding: 0, - code: "MKD", + value: "MKD", name_plural: "Macedonian denari", + countryCode: "MK", }, - MMK: { - symbol: "MMK", - name: "Myanma Kyat", - symbol_native: "K", + { + symbol: "MGA", + label: "Malagasy Ariary", + symbol_native: "MGA", decimal_digits: 0, rounding: 0, - code: "MMK", - name_plural: "Myanma kyats", + value: "MGA", + name_plural: "Malagasy Ariaries", + countryCode: "MG", }, - MOP: { - symbol: "MOP$", - name: "Macanese Pataca", - symbol_native: "MOP$", + { + symbol: "RM", + label: "Malaysian Ringgit", + symbol_native: "RM", decimal_digits: 2, rounding: 0, - code: "MOP", - name_plural: "Macanese patacas", + value: "MYR", + name_plural: "Malaysian ringgits", + countryCode: "MY", }, - MUR: { + { symbol: "MURs", - name: "Mauritian Rupee", + label: "Mauritian Rupee", symbol_native: "MURs", decimal_digits: 0, rounding: 0, - code: "MUR", + value: "MUR", name_plural: "Mauritian rupees", + countryCode: "MU", }, - MXN: { + { symbol: "MX$", - name: "Mexican Peso", + label: "Mexican Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "MXN", + value: "MXN", name_plural: "Mexican pesos", + countryCode: "MX", }, - MYR: { - symbol: "RM", - name: "Malaysian Ringgit", - symbol_native: "RM", + { + symbol: "MDL", + label: "Moldovan Leu", + symbol_native: "MDL", decimal_digits: 2, rounding: 0, - code: "MYR", - name_plural: "Malaysian ringgits", + value: "MDL", + name_plural: "Moldovan lei", + countryCode: "MD", + }, + { + symbol: "MAD", + label: "Moroccan Dirham", + symbol_native: "د.م.‏", + decimal_digits: 2, + rounding: 0, + value: "MAD", + name_plural: "Moroccan dirhams", + countryCode: "MA", }, - MZN: { + { symbol: "MTn", - name: "Mozambican Metical", + label: "Mozambican Metical", symbol_native: "MTn", decimal_digits: 2, rounding: 0, - code: "MZN", + value: "MZN", name_plural: "Mozambican meticals", + countryCode: "MZ", + }, + { + symbol: "MMK", + label: "Myanma Kyat", + symbol_native: "K", + decimal_digits: 0, + rounding: 0, + value: "MMK", + name_plural: "Myanma kyats", + countryCode: "MM", }, - NAD: { + { symbol: "N$", - name: "Namibian Dollar", + label: "Namibian Dollar", symbol_native: "N$", decimal_digits: 2, rounding: 0, - code: "NAD", + value: "NAD", name_plural: "Namibian dollars", + countryCode: "NA", }, - NGN: { - symbol: "₦", - name: "Nigerian Naira", - symbol_native: "₦", + { + symbol: "NPRs", + label: "Nepalese Rupee", + symbol_native: "नेरू", decimal_digits: 2, rounding: 0, - code: "NGN", - name_plural: "Nigerian nairas", + value: "NPR", + name_plural: "Nepalese rupees", + countryCode: "NP", }, - NIO: { - symbol: "C$", - name: "Nicaraguan Córdoba", - symbol_native: "C$", + { + symbol: "NT$", + label: "New Taiwan Dollar", + symbol_native: "NT$", decimal_digits: 2, rounding: 0, - code: "NIO", - name_plural: "Nicaraguan córdobas", + value: "TWD", + name_plural: "New Taiwan dollars", + countryCode: "TW", }, - NOK: { - symbol: "Nkr", - name: "Norwegian Krone", - symbol_native: "kr", + { + symbol: "NZ$", + label: "New Zealand Dollar", + symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "NOK", - name_plural: "Norwegian kroner", + value: "NZD", + name_plural: "New Zealand dollars", + countryCode: "NZ", }, - NPR: { - symbol: "NPRs", - name: "Nepalese Rupee", - symbol_native: "नेरू", + { + symbol: "C$", + label: "Nicaraguan Córdoba", + symbol_native: "C$", decimal_digits: 2, rounding: 0, - code: "NPR", - name_plural: "Nepalese rupees", + value: "NIO", + name_plural: "Nicaraguan córdobas", + countryCode: "NI", }, - NZD: { - symbol: "NZ$", - name: "New Zealand Dollar", - symbol_native: "$", + { + symbol: "₦", + label: "Nigerian Naira", + symbol_native: "₦", decimal_digits: 2, rounding: 0, - code: "NZD", - name_plural: "New Zealand dollars", + value: "NGN", + name_plural: "Nigerian nairas", + countryCode: "NG", + }, + { + symbol: "Nkr", + label: "Norwegian Krone", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + value: "NOK", + name_plural: "Norwegian kroner", + countryCode: "NO", }, - OMR: { + { symbol: "OMR", - name: "Omani Rial", + label: "Omani Rial", symbol_native: "ر.ع.‏", decimal_digits: 3, rounding: 0, - code: "OMR", + value: "OMR", name_plural: "Omani rials", + countryCode: "OM", + }, + { + symbol: "PKRs", + label: "Pakistani Rupee", + symbol_native: "₨", + decimal_digits: 0, + rounding: 0, + value: "PKR", + name_plural: "Pakistani rupees", + countryCode: "PK", }, - PAB: { + { symbol: "B/.", - name: "Panamanian Balboa", + label: "Panamanian Balboa", symbol_native: "B/.", decimal_digits: 2, rounding: 0, - code: "PAB", + value: "PAB", name_plural: "Panamanian balboas", + countryCode: "PA", + }, + { + symbol: "₲", + label: "Paraguayan Guarani", + symbol_native: "₲", + decimal_digits: 0, + rounding: 0, + value: "PYG", + name_plural: "Paraguayan guaranis", + countryCode: "PY", }, - PEN: { + { symbol: "S/.", - name: "Peruvian Nuevo Sol", + label: "Peruvian Nuevo Sol", symbol_native: "S/.", decimal_digits: 2, rounding: 0, - code: "PEN", + value: "PEN", name_plural: "Peruvian nuevos soles", + countryCode: "PE", }, - PHP: { + { symbol: "₱", - name: "Philippine Peso", + label: "Philippine Peso", symbol_native: "₱", decimal_digits: 2, rounding: 0, - code: "PHP", + value: "PHP", name_plural: "Philippine pesos", + countryCode: "PH", }, - PKR: { - symbol: "PKRs", - name: "Pakistani Rupee", - symbol_native: "₨", - decimal_digits: 0, - rounding: 0, - code: "PKR", - name_plural: "Pakistani rupees", - }, - PLN: { + { symbol: "zł", - name: "Polish Zloty", + label: "Polish Zloty", symbol_native: "zł", decimal_digits: 2, rounding: 0, - code: "PLN", + value: "PLN", name_plural: "Polish zlotys", + countryCode: "PL", }, - PYG: { - symbol: "₲", - name: "Paraguayan Guarani", - symbol_native: "₲", - decimal_digits: 0, - rounding: 0, - code: "PYG", - name_plural: "Paraguayan guaranis", - }, - QAR: { + { symbol: "QR", - name: "Qatari Rial", + label: "Qatari Rial", symbol_native: "ر.ق.‏", decimal_digits: 2, rounding: 0, - code: "QAR", + value: "QAR", name_plural: "Qatari rials", + countryCode: "QA", }, - RON: { + { symbol: "RON", - name: "Romanian Leu", + label: "Romanian Leu", symbol_native: "RON", decimal_digits: 2, rounding: 0, - code: "RON", + value: "RON", name_plural: "Romanian lei", + countryCode: "RO", }, - RSD: { - symbol: "din.", - name: "Serbian Dinar", - symbol_native: "дин.", - decimal_digits: 0, - rounding: 0, - code: "RSD", - name_plural: "Serbian dinars", - }, - RUB: { + { symbol: "RUB", - name: "Russian Ruble", + label: "Russian Ruble", symbol_native: "руб.", decimal_digits: 2, rounding: 0, - code: "RUB", + value: "RUB", name_plural: "Russian rubles", + countryCode: "RU", }, - RWF: { + { symbol: "RWF", - name: "Rwandan Franc", + label: "Rwandan Franc", symbol_native: "FR", decimal_digits: 0, rounding: 0, - code: "RWF", + value: "RWF", name_plural: "Rwandan francs", + countryCode: "RW", }, - SAR: { + { symbol: "SR", - name: "Saudi Riyal", + label: "Saudi Riyal", symbol_native: "ر.س.‏", decimal_digits: 2, rounding: 0, - code: "SAR", + value: "SAR", name_plural: "Saudi riyals", + countryCode: "SA", }, - SDG: { - symbol: "SDG", - name: "Sudanese Pound", - symbol_native: "SDG", - decimal_digits: 2, - rounding: 0, - code: "SDG", - name_plural: "Sudanese pounds", - }, - SEK: { - symbol: "Skr", - name: "Swedish Krona", - symbol_native: "kr", - decimal_digits: 2, + { + symbol: "din.", + label: "Serbian Dinar", + symbol_native: "дин.", + decimal_digits: 0, rounding: 0, - code: "SEK", - name_plural: "Swedish kronor", + value: "RSD", + name_plural: "Serbian dinars", + countryCode: "RS", }, - SGD: { + { symbol: "S$", - name: "Singapore Dollar", + label: "Singapore Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "SGD", + value: "SGD", name_plural: "Singapore dollars", + countryCode: "SG", }, - SOS: { + { symbol: "Ssh", - name: "Somali Shilling", + label: "Somali Shilling", symbol_native: "Ssh", decimal_digits: 0, rounding: 0, - code: "SOS", + value: "SOS", name_plural: "Somali shillings", + countryCode: "SO", + }, + { + symbol: "R", + label: "South African Rand", + symbol_native: "R", + decimal_digits: 2, + rounding: 0, + value: "ZAR", + name_plural: "South African rand", + countryCode: "ZA", + }, + { + symbol: "₩", + label: "South Korean Won", + symbol_native: "₩", + decimal_digits: 0, + rounding: 0, + value: "KRW", + name_plural: "South Korean won", + countryCode: "KR", + }, + { + symbol: "SLRs", + label: "Sri Lankan Rupee", + symbol_native: "SL Re", + decimal_digits: 2, + rounding: 0, + value: "LKR", + name_plural: "Sri Lankan rupees", + countryCode: "LK", + }, + { + symbol: "SDG", + label: "Sudanese Pound", + symbol_native: "SDG", + decimal_digits: 2, + rounding: 0, + value: "SDG", + name_plural: "Sudanese pounds", + countryCode: "SD", + }, + { + symbol: "Skr", + label: "Swedish Krona", + symbol_native: "kr", + decimal_digits: 2, + rounding: 0, + value: "SEK", + name_plural: "Swedish kronor", + countryCode: "SE", + }, + { + symbol: "CHF", + label: "Swiss Franc", + symbol_native: "CHF", + decimal_digits: 2, + rounding: 0.05, + value: "CHF", + name_plural: "Swiss francs", + countryCode: "CH", }, - SYP: { + { symbol: "SY£", - name: "Syrian Pound", + label: "Syrian Pound", symbol_native: "ل.س.‏", decimal_digits: 0, rounding: 0, - code: "SYP", + value: "SYP", name_plural: "Syrian pounds", + countryCode: "SY", + }, + { + symbol: "TSh", + label: "Tanzanian Shilling", + symbol_native: "TSh", + decimal_digits: 0, + rounding: 0, + value: "TZS", + name_plural: "Tanzanian shillings", + countryCode: "TZ", }, - THB: { + { symbol: "฿", - name: "Thai Baht", + label: "Thai Baht", symbol_native: "฿", decimal_digits: 2, rounding: 0, - code: "THB", + value: "THB", name_plural: "Thai baht", + countryCode: "TH", }, - TND: { - symbol: "DT", - name: "Tunisian Dinar", - symbol_native: "د.ت.‏", - decimal_digits: 3, - rounding: 0, - code: "TND", - name_plural: "Tunisian dinars", - }, - TOP: { + { symbol: "T$", - name: "Tongan Paʻanga", + label: "Tongan Paʻanga", symbol_native: "T$", decimal_digits: 2, rounding: 0, - code: "TOP", + value: "TOP", name_plural: "Tongan paʻanga", + countryCode: "TO", }, - TRY: { - symbol: "TL", - name: "Turkish Lira", - symbol_native: "TL", - decimal_digits: 2, - rounding: 0, - code: "TRY", - name_plural: "Turkish Lira", - }, - TTD: { + { symbol: "TT$", - name: "Trinidad and Tobago Dollar", + label: "Trinidad and Tobago Dollar", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "TTD", + value: "TTD", name_plural: "Trinidad and Tobago dollars", + countryCode: "TT", }, - TWD: { - symbol: "NT$", - name: "New Taiwan Dollar", - symbol_native: "NT$", + { + symbol: "DT", + label: "Tunisian Dinar", + symbol_native: "د.ت.‏", + decimal_digits: 3, + rounding: 0, + value: "TND", + name_plural: "Tunisian dinars", + countryCode: "TN", + }, + { + symbol: "TL", + label: "Turkish Lira", + symbol_native: "TL", decimal_digits: 2, rounding: 0, - code: "TWD", - name_plural: "New Taiwan dollars", + value: "TRY", + name_plural: "Turkish Lira", + countryCode: "TR", }, - TZS: { - symbol: "TSh", - name: "Tanzanian Shilling", - symbol_native: "TSh", + { + symbol: "USh", + label: "Ugandan Shilling", + symbol_native: "USh", decimal_digits: 0, rounding: 0, - code: "TZS", - name_plural: "Tanzanian shillings", + value: "UGX", + name_plural: "Ugandan shillings", + countryCode: "UG", }, - UAH: { + { symbol: "₴", - name: "Ukrainian Hryvnia", + label: "Ukrainian Hryvnia", symbol_native: "₴", decimal_digits: 2, rounding: 0, - code: "UAH", + value: "UAH", name_plural: "Ukrainian hryvnias", + countryCode: "UA", }, - UGX: { - symbol: "USh", - name: "Ugandan Shilling", - symbol_native: "USh", - decimal_digits: 0, + { + symbol: "AED", + label: "United Arab Emirates Dirham", + symbol_native: "د.إ.‏", + decimal_digits: 2, rounding: 0, - code: "UGX", - name_plural: "Ugandan shillings", + value: "AED", + name_plural: "UAE dirhams", + countryCode: "AE", + }, + { + symbol: " $ ", + label: "United States Dollar", + symbol_native: "$", + decimal_digits: 2, + rounding: 0, + value: "USD", + name_plural: "US dollars", + countryCode: "US", }, - UYU: { + { symbol: "$U", - name: "Uruguayan Peso", + label: "Uruguayan Peso", symbol_native: "$", decimal_digits: 2, rounding: 0, - code: "UYU", + value: "UYU", name_plural: "Uruguayan pesos", + countryCode: "UY", }, - UZS: { + { symbol: "UZS", - name: "Uzbekistan Som", + label: "Uzbekistan Som", symbol_native: "UZS", decimal_digits: 0, rounding: 0, - code: "UZS", + value: "UZS", name_plural: "Uzbekistan som", + countryCode: "UZ", }, - VEF: { + { symbol: "Bs.F.", - name: "Venezuelan Bolívar", + label: "Venezuelan Bolívar", symbol_native: "Bs.F.", decimal_digits: 2, rounding: 0, - code: "VEF", + value: "VEF", name_plural: "Venezuelan bolívars", + countryCode: "VE", }, - VND: { + { symbol: "₫", - name: "Vietnamese Dong", + label: "Vietnamese Dong", symbol_native: "₫", decimal_digits: 0, rounding: 0, - code: "VND", + value: "VND", name_plural: "Vietnamese dong", + countryCode: "VN", }, - XAF: { - symbol: "FCFA", - name: "CFA Franc BEAC", - symbol_native: "FCFA", - decimal_digits: 0, - rounding: 0, - code: "XAF", - name_plural: "CFA francs BEAC", - }, - XOF: { - symbol: "CFA", - name: "CFA Franc BCEAO", - symbol_native: "CFA", - decimal_digits: 0, - rounding: 0, - code: "XOF", - name_plural: "CFA francs BCEAO", - }, - YER: { + { symbol: "YR", - name: "Yemeni Rial", + label: "Yemeni Rial", symbol_native: "ر.ي.‏", decimal_digits: 0, rounding: 0, - code: "YER", + value: "YER", name_plural: "Yemeni rials", + countryCode: "YE", }, - ZAR: { - symbol: "R", - name: "South African Rand", - symbol_native: "R", - decimal_digits: 2, - rounding: 0, - code: "ZAR", - name_plural: "South African rand", - }, - ZMK: { + { symbol: "ZK", - name: "Zambian Kwacha", + label: "Zambian Kwacha", symbol_native: "ZK", decimal_digits: 0, rounding: 0, - code: "ZMK", + value: "ZMK", name_plural: "Zambian kwachas", + countryCode: "ZM", }, -}; +]; diff --git a/src/shell/components/FieldTypeCurrency/index.js b/src/shell/components/FieldTypeCurrency/index.js deleted file mode 100644 index ad300a9373..0000000000 --- a/src/shell/components/FieldTypeCurrency/index.js +++ /dev/null @@ -1 +0,0 @@ -export { FieldTypeCurrency } from "./FieldTypeCurrency"; diff --git a/src/shell/components/FieldTypeCurrency/index.tsx b/src/shell/components/FieldTypeCurrency/index.tsx new file mode 100644 index 0000000000..a74cad89c9 --- /dev/null +++ b/src/shell/components/FieldTypeCurrency/index.tsx @@ -0,0 +1,64 @@ +import { useMemo } from "react"; +import { TextField, Typography, Box, Stack } from "@mui/material"; + +import { currencies } from "./currencies"; +import { NumberFormatInput } from "../NumberFormatInput"; + +type FieldTypeCurrencyProps = { + name: string; + value: string; + currency: string; + error: boolean; + onChange: (value: string, name: string) => void; +}; +export const FieldTypeCurrency = ({ + name, + currency, + value, + error, + onChange, + ...otherProps +}: FieldTypeCurrencyProps) => { + const selectedCurrency = useMemo(() => { + return currencies.find((_currency) => _currency.value === currency); + }, [currency]); + + return ( + onChange(evt?.target?.value?.value, name)} + InputProps={{ + inputComponent: NumberFormatInput as any, + inputProps: { + thousandSeparator: true, + valueIsNumericString: true, + }, + startAdornment: ( + + {selectedCurrency?.symbol_native} + + ), + endAdornment: ( + + + + {selectedCurrency.value} + + + ), + }} + /> + ); +}; diff --git a/src/shell/components/FieldTypeNumber.tsx b/src/shell/components/FieldTypeNumber.tsx index aacf8169f1..5329ea7020 100644 --- a/src/shell/components/FieldTypeNumber.tsx +++ b/src/shell/components/FieldTypeNumber.tsx @@ -66,8 +66,10 @@ export const FieldTypeNumber = ({ value={value || 0} name={name} required={required} - onChange={(evt) => { - onChange(+evt.target.value?.toString()?.replace(/^0+/, "") ?? 0, name); + onChange={(evt: any) => { + const value = evt?.target?.value?.floatValue ?? 0; + + onChange(+value?.toString()?.replace(/^0+/, "") ?? 0, name); }} onKeyDown={(evt) => { if ((evt.key === "Backspace" || evt.key === "Delete") && value === 0) { diff --git a/src/shell/components/Filters/FilterButton.tsx b/src/shell/components/Filters/FilterButton.tsx index 04f3874e6f..917d7d5e1e 100644 --- a/src/shell/components/Filters/FilterButton.tsx +++ b/src/shell/components/Filters/FilterButton.tsx @@ -1,6 +1,6 @@ import { FC } from "react"; import { Button, ButtonGroup, Typography } from "@mui/material"; -import ArrowDropDownOutlinedIcon from "@mui/icons-material/ArrowDropDownOutlined"; +import KeyboardArrowDownRoundedIcon from "@mui/icons-material/KeyboardArrowDownRounded"; import CheckIcon from "@mui/icons-material/Check"; import CloseRoundedIcon from "@mui/icons-material/CloseRounded"; @@ -53,7 +53,7 @@ export const FilterButton: FC = ({ variant="outlined" size="small" color="inherit" - endIcon={} + endIcon={} onClick={onOpenMenu} data-cy={`${filterId}_default`} sx={{ diff --git a/src/shell/components/Head/Head.less b/src/shell/components/Head/Head.less index 92c16a8803..b58ab408d2 100644 --- a/src/shell/components/Head/Head.less +++ b/src/shell/components/Head/Head.less @@ -2,12 +2,13 @@ @import "~@zesty-io/core/typography.less"; .Head { + height: 100%; display: flex; flex-direction: column-reverse; display: grid; grid-template-columns: minmax(400px, 1fr) 1fr; .Tags { - height: calc(100vh - 54px); + height: 100%; overflow-y: scroll; margin-left: 16px; } diff --git a/src/shell/components/Head/Preview/Preview.less b/src/shell/components/Head/Preview/Preview.less index a50a626fad..8218cdb6ca 100644 --- a/src/shell/components/Head/Preview/Preview.less +++ b/src/shell/components/Head/Preview/Preview.less @@ -3,6 +3,7 @@ overflow: scroll; .TagPreview { + height: 100%; font-size: 14px; font-family: "Courier New", Courier, monospace; padding: 16px; diff --git a/src/shell/components/InviteMembersModal/index.tsx b/src/shell/components/InviteMembersModal/index.tsx index 5c5b5ce2b0..5e6b9a7035 100644 --- a/src/shell/components/InviteMembersModal/index.tsx +++ b/src/shell/components/InviteMembersModal/index.tsx @@ -21,7 +21,7 @@ import { useGetCurrentUserRolesQuery, } from "../../services/accounts"; import { LoadingButton } from "@mui/lab"; -import { NoPermission } from "./NoPermission"; +import { NoPermission } from "../NoPermission"; import instanzeZUID from "../../../utility/instanceZUID"; import { ConfirmationModal } from "./ConfirmationDialog"; diff --git a/src/shell/components/InviteMembersModal/NoPermission.tsx b/src/shell/components/NoPermission.tsx similarity index 80% rename from src/shell/components/InviteMembersModal/NoPermission.tsx rename to src/shell/components/NoPermission.tsx index b22c8d1bc3..9ca0ec879f 100644 --- a/src/shell/components/InviteMembersModal/NoPermission.tsx +++ b/src/shell/components/NoPermission.tsx @@ -15,14 +15,20 @@ import { } from "@mui/material"; import ErrorRoundedIcon from "@mui/icons-material/ErrorRounded"; -import { useGetUsersRolesQuery } from "../../services/accounts"; -import { MD5 } from "../../../utility/md5"; +import { useGetUsersRolesQuery } from "../services/accounts"; +import { MD5 } from "../../utility/md5"; type NoPermissionProps = { onClose: () => void; + headerTitle?: string; + headerSubtitle?: string; }; -export const NoPermission = ({ onClose }: NoPermissionProps) => { +export const NoPermission = ({ + onClose, + headerSubtitle, + headerTitle, +}: NoPermissionProps) => { const { data: users } = useGetUsersRolesQuery(); const ownersAndAdmins = useMemo(() => { @@ -51,12 +57,14 @@ export const NoPermission = ({ onClose }: NoPermissionProps) => { }} /> - You do not have permission to invite users + {headerTitle + ? headerTitle + : "You do not have permission to invite users"} - Contact your instance owners or administrators listed below to change - your role to Admin or Owner on this instance for user invitation - priveleges. + {headerSubtitle + ? headerSubtitle + : "Contact your instance owners or administrators listed below to change your role to Admin or Owner on this instance for user invitation priveleges."} diff --git a/src/shell/components/NumberFormatInput/index.tsx b/src/shell/components/NumberFormatInput/index.tsx index 6efa07b581..db7effd76a 100644 --- a/src/shell/components/NumberFormatInput/index.tsx +++ b/src/shell/components/NumberFormatInput/index.tsx @@ -3,10 +3,17 @@ import { NumericFormatProps, InputAttributes, NumericFormat, + NumberFormatValues, } from "react-number-format"; +export type NumberFormatInputEvent = { + target: { + name: string; + value: NumberFormatValues; + }; +}; type NumberFormatInputProps = { - onChange: (event: { target: { name: string; value: number } }) => void; + onChange: (event: NumberFormatInputEvent) => void; name: string; }; export const NumberFormatInput = forwardRef< @@ -23,7 +30,7 @@ export const NumberFormatInput = forwardRef< onChange({ target: { name: props.name, - value: values.floatValue || 0, + value: values, }, }); }} diff --git a/src/shell/components/global-sidebar/components/InstanceAvatar.tsx b/src/shell/components/global-sidebar/components/InstanceAvatar.tsx index e8d29ed5e2..0ad3492d8d 100644 --- a/src/shell/components/global-sidebar/components/InstanceAvatar.tsx +++ b/src/shell/components/global-sidebar/components/InstanceAvatar.tsx @@ -1,6 +1,6 @@ import { FC, useMemo } from "react"; import { useSelector, useDispatch } from "react-redux"; -import { Avatar, Skeleton, Box } from "@mui/material"; +import { Avatar, Skeleton, Box, SxProps, Theme } from "@mui/material"; import ImageRoundedIcon from "@mui/icons-material/ImageRounded"; import { useGetHeadTagsQuery } from "../../../services/instance"; @@ -11,10 +11,12 @@ import { actions } from "../../../store/ui"; interface InstanceAvatar { canUpdateAvatar?: boolean; onFaviconModalOpen?: () => void; + avatarSx?: SxProps; } export const InstanceAvatar: FC = ({ canUpdateAvatar = true, onFaviconModalOpen, + avatarSx, }) => { const ui = useSelector((state: AppState) => state.ui); const dispatch = useDispatch(); @@ -65,6 +67,7 @@ export const InstanceAvatar: FC = ({ height: 32, width: 32, backgroundColor: faviconURL ? "common.white" : "info.main", + ...avatarSx, }} > {(!faviconURL && instance?.name[0]?.toUpperCase()) || "A"} diff --git a/src/shell/components/withAi/AIGenerator.tsx b/src/shell/components/withAi/AIGenerator.tsx index 6306cf650e..4b72c46f14 100644 --- a/src/shell/components/withAi/AIGenerator.tsx +++ b/src/shell/components/withAi/AIGenerator.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, useRef } from "react"; +import { + useEffect, + useState, + useRef, + useMemo, + useContext, + useReducer, +} from "react"; import { Button, Box, @@ -10,49 +17,225 @@ import { MenuItem, Autocomplete, CircularProgress, + Stack, + InputAdornment, + Tooltip, + alpha, + ListItemButton, } from "@mui/material"; -import CloseIcon from "@mui/icons-material/Close"; import StopRoundedIcon from "@mui/icons-material/StopRounded"; import CheckRoundedIcon from "@mui/icons-material/CheckRounded"; import RefreshRoundedIcon from "@mui/icons-material/RefreshRounded"; -import { useAiGenerationMutation } from "../../services/cloudFunctions"; -import { useGetLangsMappingQuery } from "../../services/instance"; +import LanguageRoundedIcon from "@mui/icons-material/LanguageRounded"; +import InfoRoundedIcon from "@mui/icons-material/InfoRounded"; import { Brain } from "@zesty-io/material"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; +import { useLocation, useParams } from "react-router"; + import { notify } from "../../store/notifications"; +import openAIBadge from "../../../../public/images/openai-badge.svg"; +import { FieldTypeNumber } from "../FieldTypeNumber"; +import { useAiGenerationMutation } from "../../services/cloudFunctions"; +import { + useGetContentModelFieldsQuery, + useGetLangsMappingQuery, +} from "../../services/instance"; +import { AppState } from "../../store/types"; +import { AIGeneratorContext } from "./AIGeneratorProvider"; + +const DEFAULT_LIMITS: Record = { + text: 150, + paragraph: 3, + word: 1500, + description: 160, + title: 150, +}; +export const TONE_OPTIONS = [ + { + value: "intriguing", + label: "Intriguing - Curious, mysterious, and thought-provoking", + }, + { + value: "professional", + label: "Professional - Serious, formal, and authoritative", + }, + { value: "playful", label: "Playful - Fun, light-hearted, and whimsical" }, + { + value: "sensational", + label: "Sensational - Bold, dramatic, and attention-grabbing", + }, + { value: "succint", label: "Succinct - Clear, factual, with no hyperbole" }, +] as const; +export type ToneOption = + | "intriguing" + | "professional" + | "playful" + | "sensational" + | "succint"; + +type FieldData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; +}; +// description and title are used for seo meta title & description +type AIType = "text" | "paragraph" | "description" | "title" | "word"; interface Props { onApprove: (data: string) => void; - onClose: () => void; - aiType: string; + onClose: (reason: "close" | "insert") => void; + aiType: AIType; label: string; + fieldZUID: string; + isAIAssistedFlow: boolean; } -export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { +export const AIGenerator = ({ + onApprove, + onClose, + aiType, + label, + fieldZUID, + isAIAssistedFlow, +}: Props) => { const dispatch = useDispatch(); - const [topic, setTopic] = useState(""); - const [limit, setLimit] = useState(aiType === "text" ? "150" : "3"); - const request = useRef(null); - const [language, setLanguage] = useState({ - label: "English (United States)", - value: "en-US", + const location = useLocation(); + const isCreateItemPage = location?.pathname?.split("/")?.pop() === "new"; + const [hasFieldError, setHasFieldError] = useState(false); + const { modelZUID, itemZUID } = useParams<{ + modelZUID: string; + itemZUID: string; + }>(); + const item = useSelector( + (state: AppState) => + state.content[isCreateItemPage ? `new:${modelZUID}` : itemZUID] + ); + const { data: fields } = useGetContentModelFieldsQuery(modelZUID, { + skip: !modelZUID, }); - - const [data, setData] = useState(""); + const [selectedContent, setSelectedContent] = useState(null); + const request = useRef(null); + const [fieldData, updateFieldData] = useReducer( + (state: FieldData, action: Partial) => { + return { + ...state, + ...action, + }; + }, + { + topic: "", + audienceDescription: "", + tone: "professional", + keywords: "", + limit: DEFAULT_LIMITS[aiType], + language: { + label: "English (United States)", + value: "en-US", + }, + } + ); + const [data, setData] = useState([]); const { data: langMappings } = useGetLangsMappingQuery(); + const [ + lastOpenedZUID, + updateLastOpenedZUID, + parameterData, + updateParameterData, + ] = useContext(AIGeneratorContext); const [aiGenerate, { isLoading, isError, data: aiResponse }] = useAiGenerationMutation(); + const allTextFieldContent = useMemo(() => { + // This is really only needed for seo meta title & description + // so we skip it for other types + if ( + (aiType !== "title" && aiType !== "description") || + !fields?.length || + !Object.keys(item?.data)?.length + ) + return ""; + + const textFieldTypes = [ + "text", + "wysiwyg_basic", + "wysiwyg_advanced", + "article_writer", + "markdown", + "textarea", + ]; + + return fields.reduce((accu, curr) => { + if (!curr.deletedAt && textFieldTypes.includes(curr.datatype)) { + return (accu = `${accu} ${item.data[curr.name] || ""}`); + } + + return accu; + }, ""); + }, [fields, item?.data]); + const handleGenerate = () => { - request.current = aiGenerate({ - type: aiType, - length: limit, - phrase: topic, - lang: language.value, - }); + if (aiType === "description" || aiType === "title") { + request.current = aiGenerate({ + type: aiType, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, + content: allTextFieldContent, + keywords: fieldData.keywords, + }); + } else { + if (fieldData.topic) { + request.current = aiGenerate({ + type: aiType, + length: fieldData.limit, + phrase: fieldData.topic, + lang: fieldData.language.value, + tone: fieldData.tone, + audience: fieldData.audienceDescription, + }); + } else { + setHasFieldError(true); + } + } }; + useEffect(() => { + // Used to automatically popuplate the data if they reopened the AI Generator + // on the same field or if the current field is the metaDescription field and + // is currently going through the AI assisted flow + if ( + lastOpenedZUID === fieldZUID || + (isAIAssistedFlow && fieldZUID === "metaDescription") + ) { + try { + const key = + isAIAssistedFlow && fieldZUID === "metaDescription" + ? "metaTitle" + : fieldZUID; + const { topic, audienceDescription, tone, keywords, limit, language } = + parameterData[key]; + + updateFieldData({ + topic, + audienceDescription, + tone, + ...(!!limit && { limit: limit }), + language, + keywords, + }); + } catch (err) { + console.error(err); + } + } + }, [parameterData, lastOpenedZUID, isAIAssistedFlow]); + useEffect(() => { if (isError) { dispatch( @@ -66,7 +249,24 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { useEffect(() => { if (aiResponse?.data) { - setData(aiResponse.data); + // For description and title, response will be a stringified array + if (aiType === "description" || aiType === "title") { + try { + const responseArr = JSON.parse(aiResponse.data); + + if (Array.isArray(responseArr)) { + const cleanedResponse = responseArr.map((response) => + response?.replace(/^"(.*)"$/, "$1") + ); + + setData(cleanedResponse); + } + } catch (err) { + console.error("Error parsing AI response: ", err); + } + } else { + setData([aiResponse.data.replace(/^"(.*)"$/, "$1")]); + } } }, [aiResponse]); @@ -77,147 +277,584 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { }) ); + const handleClose = (reason: "close" | "insert") => { + // Temporarily save all the inputs when closing the popup so + // that if they reopen it again, we can repopulate the fields + updateLastOpenedZUID(fieldZUID); + updateParameterData({ + [fieldZUID]: { + topic: fieldData.topic, + limit: fieldData.limit, + language: fieldData.language, + tone: fieldData.tone, + audienceDescription: fieldData.audienceDescription, + keywords: fieldData.keywords, + }, + }); + + // Reason is used to determine if the AI assisted flow will be cancelled + // or not + onClose(reason); + }; + + // Loading if (isLoading) { return ( - - - - + + + + + + + Generating + {aiType === "title" + ? " Meta Title" + : aiType === "description" + ? " Meta Description" + : " Content"} + + + {aiType === "title" + ? "Our AI assistant is scanning your content and generating your meta title " + : aiType === "description" + ? "Our AI assistant is scanning your content and generating your meta description " + : "Our AI assistant is generating your content "} + based on your parameters + + + + + ); + } + + // Meta Title and Meta Description field types + if (aiType === "title" || aiType === "description") { + return ( + + + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Select" : "Generate"} Meta{" "} + {aiType === "title" ? "Title" : "Description"} + + + {!!data?.length + ? `Select 1 out of the 3 Meta ${ + aiType === "title" ? "Titles" : "Descriptions" + } our AI has generated for you.` + : `Our AI will scan your content and generate your meta ${ + aiType === "title" ? "title" : "description" + } for you based on your parameters set below`} + + + + + {!!data?.length ? ( + + {data.map((value, index) => ( + setSelectedContent(index)} + sx={{ + borderRadius: 2, + border: 1, + borderColor: "border", + backgroundColor: "common.white", + px: 1.5, + py: 2, + flexDirection: "column", + alignItems: "flex-start", + + "&.Mui-selected": { + borderColor: "primary.main", + }, + }} + > + + OPTION {index + 1} + + + {String(value)} + + + ))} + + ) : ( + + + Describe your Audience + + updateFieldData({ audienceDescription: evt.target.value }) + } + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Keywords to Include (separated by commas) + + + updateFieldData({ keywords: evt.target.value }) + } + placeholder="e.g. Hikes, snow" + fullWidth + /> + + + + Tone + + + + + + option.value === value.value + } + onChange={(_, value) => + updateFieldData({ tone: value.value }) + } + value={TONE_OPTIONS.find( + (option) => option.value === fieldData.tone + )} + options={TONE_OPTIONS} + renderInput={(params: any) => ( + + )} + /> + + + + Language + + + + + + option.value === value.value + } + onChange={(event, value) => + updateFieldData({ language: value }) + } + value={fieldData.language as any} + options={languageOptions} + renderInput={(params: any) => ( + + + + ), + }} + /> + )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} + /> + + + )} - - Generating Content - - - + + {!!data?.length ? ( + + + + + ) : ( + + )} + + ); } + // Content item field types return ( - - + `1px solid ${theme.palette.border}`, - }} + p={2.25} + gap={1.5} + bgcolor="background.paper" + borderRadius="2px 2px 0 0" > - - - - {data ? "Your Content is Generated!" : "Generate Content"} + + + theme.palette.common.white }} /> + + + + + + {!!data?.length ? "Your Content is Generated!" : "Generate Content"} - - - - - + + {!!data?.length + ? "Our AI assistant can make mistakes. Please check important info." + : "Use our AI assistant to write content for you"} + + + `1px solid ${theme.palette.border}`, + overflowY: "auto", + borderTop: 1, + borderBottom: 1, + borderColor: "border", }} > - {data ? ( + {!!data?.length ? ( - {label} + Generated Content setData(event.target.value)} + value={data[0]} + onChange={(event) => setData([event.target.value])} multiline - rows={8} + rows={15} fullWidth /> ) : ( - <> - Topic - - Describe what you want the AI to write for you - - setTopic(event.target.value)} - placeholder={`e.g. "Hikes in Washington"`} - multiline - rows={2} - fullWidth - /> - - - {aiType === "text" && ( - <> - Character Limit - setLimit(event.target.value)} - fullWidth - /> - + + + Topic * + { + if (!!event.target.value) { + setHasFieldError(false); + } + + updateFieldData({ topic: event.target.value }); + }} + placeholder={`e.g. Hikes in Washington`} + multiline + rows={3} + fullWidth + error={hasFieldError} + helperText={ + hasFieldError && + "This is field is required. Please enter a value." + } + /> + + + Describe your Audience + + updateFieldData({ audienceDescription: evt.target.value }) + } + placeholder="e.g. Freelancers, Designers, ....." + fullWidth + /> + + + + Tone + + + + + + option.value === value.value + } + onChange={(_, value) => updateFieldData({ tone: value.value })} + value={TONE_OPTIONS.find( + (option) => option.value === fieldData.tone )} - {aiType === "paragraph" && ( - <> - Paragraph Limit - - + options={TONE_OPTIONS} + renderInput={(params: any) => ( + )} + /> + + + + + + {aiType === "text" && "Character"} + {aiType === "paragraph" && "Word"} Limit + + + + + + updateFieldData({ limit: value })} + hasError={false} + /> - - Language + + + Language + + + + option.value === value.value } - onChange={(event, value) => setLanguage(value)} - value={language as any} + onChange={(event, value) => + updateFieldData({ language: value }) + } + value={fieldData.language as any} options={languageOptions} renderInput={(params: any) => ( - + + + + ), + }} + /> )} + slotProps={{ + paper: { + sx: { + maxHeight: 300, + }, + }, + }} /> - - + + )} - - - {data ? ( + {!!data?.length ? ( ) : ( @@ -245,12 +882,11 @@ export const AIGenerator = ({ onApprove, onClose, aiType, label }: Props) => { data-cy="AIGenerate" variant="contained" onClick={handleGenerate} - disabled={!topic} > Generate )} - + ); }; diff --git a/src/shell/components/withAi/AIGeneratorProvider.tsx b/src/shell/components/withAi/AIGeneratorProvider.tsx new file mode 100644 index 0000000000..454832ce17 --- /dev/null +++ b/src/shell/components/withAi/AIGeneratorProvider.tsx @@ -0,0 +1,68 @@ +import { + Dispatch, + createContext, + useReducer, + useState, + ReactNode, +} from "react"; + +import { ToneOption } from "./AIGenerator"; + +type ParameterData = { + topic?: string; + audienceDescription: string; + tone: ToneOption; + keywords?: string; + limit?: number; + language: { + label: string; + value: string; + }; +}; +type AIGeneratorContextType = [ + string | null, + Dispatch, + Record, + Dispatch> +]; + +export const AIGeneratorContext = createContext([ + null, + () => {}, + null, + () => {}, +]); + +type AIGeneratorProviderProps = { + children?: ReactNode; +}; +export const AIGeneratorProvider = ({ children }: AIGeneratorProviderProps) => { + const [lastOpenedItem, setLastOpenedItem] = useState(null); + // const [parameterData, setParameterData] = useState>(null); + const [parameterData, updateParameterData] = useReducer( + ( + state: Record, + action: Record + ) => { + const newState = { + ...state, + ...action, + }; + return newState; + }, + {} + ); + + return ( + + {children} + + ); +}; diff --git a/src/shell/components/withAi/index.tsx b/src/shell/components/withAi/index.tsx index ce93f8a519..2681deee23 100644 --- a/src/shell/components/withAi/index.tsx +++ b/src/shell/components/withAi/index.tsx @@ -1,13 +1,24 @@ -import { memo, useMemo } from "react"; -import { Popover, IconButton } from "@mui/material"; +import { useRef, forwardRef, useImperativeHandle } from "react"; +import { Popover, Button, IconButton, alpha } from "@mui/material"; import { Brain, theme } from "@zesty-io/material"; import { ThemeProvider } from "@mui/material/styles"; import { ComponentType, MouseEvent, useState } from "react"; -import { AIGenerator } from "./AIGenerator"; import { useSelector } from "react-redux"; -import { AppState } from "../../store/types"; +import { keyframes } from "@mui/system"; import moment from "moment-timezone"; + +import { AppState } from "../../store/types"; import instanceZUID from "../../../utility/instanceZUID"; +import { AIGenerator, TONE_OPTIONS } from "./AIGenerator"; + +const rotateAnimation = keyframes` + 0% { + background-position: 0% 0%; + } + 100% { + background-position: 0% 100%; + } +`; // This date is used determine if the AI feature is enabled const enabledDate = "2023-01-13"; @@ -26,109 +37,188 @@ const paragraphFormat = (text: string) => { .join("

    ")}

    `; }; -export const withAI = (WrappedComponent: ComponentType) => (props: any) => { - const instanceCreatedAt = useSelector( - (state: AppState) => state.instance.createdAt - ); - const isEnabled = - moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || - enabledZUIDs.includes(instanceZUID); - const [anchorEl, setAnchorEl] = useState(null); - const [focused, setFocused] = useState(false); - const [key, setKey] = useState(0); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - - const handleClose = () => { - setAnchorEl(null); - }; - - const handleApprove = (generatedText: string) => { - if ( - props.datatype === "article_writer" || - props.datatype === "markdown" || - props.datatype === "wysiwyg_advanced" || - props.datatype === "wysiwyg_basic" - ) { - props.onChange( - `${props.value || ""}${ - props.datatype === "markdown" - ? generatedText - : paragraphFormat(generatedText) - }`, - props.name, - props.datatype +export const withAI = (WrappedComponent: ComponentType) => + forwardRef((props: any, ref) => { + const instanceCreatedAt = useSelector( + (state: AppState) => state.instance.createdAt + ); + const isEnabled = + moment(instanceCreatedAt).isSameOrAfter(moment(enabledDate)) || + enabledZUIDs.includes(instanceZUID); + const [anchorEl, setAnchorEl] = useState(null); + const [focused, setFocused] = useState(false); + const [key, setKey] = useState(0); + const aiButtonRef = useRef(null); + + useImperativeHandle( + ref, + () => { + return { + triggerAIButton() { + if (!anchorEl) { + aiButtonRef.current?.scrollIntoView({ behavior: "smooth" }); + + // Makes sure that the popup is placed correctly after + // the scrollIntoView function is ran + setTimeout(() => { + aiButtonRef.current?.click(); + }, 500); + } + }, + }; + }, + [] + ); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = (reason: "close" | "insert") => { + if ( + reason === "close" || + (reason === "insert" && props.ZUID === "metaDescription") + ) { + // Reset the meta details flow type + props.onResetFlowType?.(); + } + setAnchorEl(null); + }; + + const handleApprove = (generatedText: string) => { + if ( + props.datatype === "article_writer" || + props.datatype === "markdown" || + props.datatype === "wysiwyg_advanced" || + props.datatype === "wysiwyg_basic" + ) { + props.onChange( + `${props.value || ""}${ + props.datatype === "markdown" + ? generatedText + : paragraphFormat(generatedText) + }`, + props.name, + props.datatype + ); + // Force re-render after appending generated AI text due to uncontrolled component + setKey(key + 1); + } else { + props.onChange( + { target: { value: `${props.value || ""}${generatedText}` } }, + props.name + ); + } + }; + + if (isEnabled) { + return ( + <> + + + + } + onFocus={() => setFocused(true)} + onBlur={() => setFocused(false)} + /> + + { + console.log("closing ai generator"); + handleClose("close"); + }} + slotProps={{ + paper: { + sx: { + overflowY: "hidden", + + "&:after": { + content: '""', + position: "absolute", + top: 0, + right: 0, + bottom: 0, + left: 0, + background: + "linear-gradient(0deg, rgba(255,93,10,1) 0%, rgba(18,183,106,1) 25%, rgba(11,165,236,1) 50%, rgba(238,70,188,1) 75%, rgba(105,56,239,1) 100%)", + animation: `${rotateAnimation} 1.5s linear alternate infinite`, + backgroundSize: "300% 300%", + }, + }, + }, + }} + > + handleClose(reason)} + aiType={props.aiType} + label={props.label} + isAIAssistedFlow={props.isAIAssistedFlow} + /> + + + ); - // Force re-render after appending generated AI text due to uncontrolled component - setKey(key + 1); } else { - props.onChange( - { target: { value: `${props.value}${generatedText}` } }, - props.name - ); + return ; } - }; - - if (isEnabled) { - return ( - <> - - - focused - ? "primary.main" - : `${theme.palette.action.active}`, - }, - "svg:hover": { - color: "primary.main", - }, - }} - onClick={(event: MouseEvent) => { - const target = event.target as HTMLElement; - if (target.nodeName === "svg" || target.nodeName === "path") { - handleClick(event); - } - }} - size="xxsmall" - > - - - - } - onFocus={() => setFocused(true)} - onBlur={() => setFocused(false)} - /> - - - - - - - ); - } else { - return ; - } -}; + }); diff --git a/src/shell/components/withCursorPosition/index.tsx b/src/shell/components/withCursorPosition/index.tsx index 94084b9b32..4bb01d4276 100644 --- a/src/shell/components/withCursorPosition/index.tsx +++ b/src/shell/components/withCursorPosition/index.tsx @@ -13,11 +13,19 @@ export const withCursorPosition = (WrappedComponent: ComponentType) => const inputRef = useRef(null); useEffect(() => { - inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); + /* + In Safari, setting the cursor position can cause the input to refocus, + leading to a poor user experience if the input isn't already focused. + This conditional check ensures the cursor position is only set if the input is focused, + preventing unnecessary refocusing on value changes, which is a problem in Safari. + */ + if (document.activeElement === inputRef.current) { + inputRef.current?.setSelectionRange(cursorPosition, cursorPosition); + } }, [props.value]); const handleChange = (e: React.ChangeEvent) => { - setCursorPosition(e.target.selectionStart); + setCursorPosition(e.target.selectionStart || 0); props.onChange && props.onChange(e); }; diff --git a/src/shell/hooks/use-permissions.js b/src/shell/hooks/use-permissions.js index 23c00df880..f2886bcdfc 100644 --- a/src/shell/hooks/use-permissions.js +++ b/src/shell/hooks/use-permissions.js @@ -33,9 +33,14 @@ export function usePermission(action, zuid = instanceZUID) { return true; } - const granularRole = role?.granularRoles?.find( - (r) => r.resourceZUID === zuid - ); + /* + If the user is not a super user, check granular roles. + First check specific resource, if not found check instance level. + TODO: Check additional granular roles for parent resources depending on resource type (e.g. content model when checking content item) + */ + const granularRole = + role?.granularRoles?.find((r) => r.resourceZUID === zuid) || + role?.granularRoles?.find((r) => r.resourceZUID === instanceZUID); // Check system switch (action) { diff --git a/src/shell/services/analytics.ts b/src/shell/services/analytics.ts new file mode 100644 index 0000000000..d14de01a81 --- /dev/null +++ b/src/shell/services/analytics.ts @@ -0,0 +1,85 @@ +import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react"; +import { getResponseData, prepareHeaders } from "./util"; +import instanceZUID from "../../utility/instanceZUID"; + +//Define service using a base URL and expected endpoints +export const analyticsApi = createApi({ + reducerPath: "analyticsApi", + baseQuery: fetchBaseQuery({ + // @ts-ignore + baseUrl: `${__CONFIG__.API_ANALYTICS}`, + prepareHeaders, + }), + endpoints: (builder) => ({ + getAnalyticsPropertyDataByQuery: builder.query({ + query: (body) => { + return { + url: `ga4/reports`, + method: "POST", + body, + params: { + zuid: instanceZUID, + }, + }; + }, + }), + getAnalyticsProperties: builder.query({ + query: () => { + return { + url: `ga4/properties`, + method: "GET", + params: { + zuid: instanceZUID, + }, + }; + }, + }), + getAnalyticsPagePathsByFilter: builder.query< + string[], + { + filter: "popular" | "gainer" | "loser"; + startDate: string; + endDate: string; + propertyId: string; + limit: number; + order: "asc" | "desc"; + } + >({ + query: ({ filter, startDate, endDate, propertyId, limit, order }) => { + return { + url: `ga4/page-paths`, + method: "GET", + params: { + q: filter, + date_start: startDate, + date_end: endDate, + property_id: propertyId, + limit, + order, + zuid: instanceZUID, + }, + }; + }, + }), + disconnectGoogleAnalytics: builder.mutation({ + query: () => { + return { + url: `ga4/auth/disconnect`, + method: "DELETE", + params: { + zuid: instanceZUID, + }, + }; + }, + }), + }), +}); + +// Export hooks for usage in functional components, which are +// auto-generated based on the defined endpoints +export const { + useGetAnalyticsPropertiesQuery, + useGetAnalyticsPropertyDataByQueryQuery, + useGetAnalyticsPagePathsByFilterQuery, + useDisconnectGoogleAnalyticsMutation, +} = analyticsApi; diff --git a/src/shell/services/cloudFunctions.ts b/src/shell/services/cloudFunctions.ts index 34a973ba03..315a17b6ac 100644 --- a/src/shell/services/cloudFunctions.ts +++ b/src/shell/services/cloudFunctions.ts @@ -23,77 +23,10 @@ export const cloudFunctionsApi = createApi({ }; }, }), - getAnalyticsPropertyDataByQuery: builder.query({ - query: (body) => { - return { - url: `getPropertyDataByQuery`, - method: "POST", - body, - params: { - zuid: instanceZUID, - }, - }; - }, - }), - getAnalyticsProperties: builder.query({ - query: () => { - return { - url: `getPropertyList`, - method: "GET", - params: { - zuid: instanceZUID, - }, - }; - }, - }), - getAnalyticsPagePathsByFilter: builder.query< - string[], - { - filter: "popular" | "gainer" | "loser"; - startDate: string; - endDate: string; - propertyId: string; - limit: number; - order: "asc" | "desc"; - } - >({ - query: ({ filter, startDate, endDate, propertyId, limit, order }) => { - return { - url: `getPagePathByFilter`, - method: "GET", - params: { - q: filter, - date_start: startDate, - date_end: endDate, - property_id: propertyId, - limit, - order, - zuid: instanceZUID, - }, - }; - }, - }), - disconnectGoogleAnalytics: builder.mutation({ - query: () => { - return { - url: `disconnectGoogleAnalytics`, - method: "DELETE", - params: { - zuid: instanceZUID, - }, - }; - }, - }), }), }); // Export hooks for usage in functional components, which are // auto-generated based on the defined endpoints -export const { - useRefreshCacheMutation, - useAiGenerationMutation, - useGetAnalyticsPropertiesQuery, - useGetAnalyticsPropertyDataByQueryQuery, - useGetAnalyticsPagePathsByFilterQuery, - useDisconnectGoogleAnalyticsMutation, -} = cloudFunctionsApi; +export const { useRefreshCacheMutation, useAiGenerationMutation } = + cloudFunctionsApi; diff --git a/src/shell/services/marketing.ts b/src/shell/services/marketing.ts index 3a0f367010..a23b553684 100644 --- a/src/shell/services/marketing.ts +++ b/src/shell/services/marketing.ts @@ -43,7 +43,7 @@ export const marketingApi = createApi({ video_link, start_date_and_time, end_date_and_time, - created_at: currVal?.version?.history?.data?.pop()?.createdAt, + created_at: currVal?.version?.createdAt, }, ]; } diff --git a/src/shell/services/mediaManager.ts b/src/shell/services/mediaManager.ts index e2d68fca3d..bfc2506a12 100644 --- a/src/shell/services/mediaManager.ts +++ b/src/shell/services/mediaManager.ts @@ -369,6 +369,7 @@ export const mediaManagerApi = createApi({ // auto-generated based on the defined endpoints export const { useGetFileQuery, + useLazyGetFileQuery, useGetBinQuery, useGetBinsQuery, useGetAllBinFilesQuery, diff --git a/src/shell/services/types.ts b/src/shell/services/types.ts index 62c7cd1aac..b8658b0d48 100644 --- a/src/shell/services/types.ts +++ b/src/shell/services/types.ts @@ -60,6 +60,8 @@ export interface File { deleted_at?: string; deleted_from_storage_at?: string; thumbnail: string; + storage_driver: string; + storage_name: string; } export type ModelType = "pageset" | "templateset" | "dataset"; @@ -203,12 +205,16 @@ export interface FieldSettings { regexRestrictErrorMessage?: string; minValue?: number; maxValue?: number; + currency?: string; + fileExtensions?: string[]; + fileExtensionsErrorMessage?: string; } export type ContentModelFieldValue = | string | number | boolean + | string[] | FieldSettings | FieldSettingsOptions[]; diff --git a/src/shell/services/util.js b/src/shell/services/util.js index a8e232048c..1b3236a582 100644 --- a/src/shell/services/util.js +++ b/src/shell/services/util.js @@ -10,5 +10,13 @@ export const prepareHeaders = (headers) => { return headers; }; -export const generateThumbnail = (file) => - `${file.url}?width=300&height=300&fit=bounds`; +export const generateThumbnail = (file) => { + if (!!file.updated_at && !isNaN(new Date(file.updated_at).getTime())) { + // Prevents browser image cache when a certain file has been already replaced + return `${file.url}?width=300&height=300&fit=bounds&versionHash=${new Date( + file.updated_at + ).getTime()}`; + } + + return `${file.url}?width=300&height=300&fit=bounds`; +}; diff --git a/src/shell/store/content.js b/src/shell/store/content.js index 1a89b29c52..401358d1bb 100644 --- a/src/shell/store/content.js +++ b/src/shell/store/content.js @@ -208,13 +208,13 @@ export function content(state = {}, action) { } // create the new item in the store -export function generateItem(modelZUID) { +export function generateItem(modelZUID, data = {}) { return (dispatch, getState) => { const state = getState(); const itemZUID = `new:${modelZUID}`; const item = { dirty: false, - data: {}, + data, web: { canonicalTagMode: 1, }, @@ -379,7 +379,11 @@ export function fetchItems(modelZUID, options = {}) { // }; // } -export function saveItem(itemZUID, action = "") { +export function saveItem({ + itemZUID, + action = "", + skipContentItemValidation = false, +}) { return (dispatch, getState) => { const state = getState(); const item = cloneDeep(state.content[itemZUID]); @@ -431,12 +435,15 @@ export function saveItem(itemZUID, action = "") { item.data[field.name] > field.settings?.maxValue) ); + // When skipContentItemValidation is true, this means that only the + // SEO meta tags were changed, so we skip validating the content item if ( - missingRequired?.length || - lackingCharLength?.length || - regexPatternMismatch?.length || - regexRestrictPatternMatch?.length || - invalidRange?.length + !skipContentItemValidation && + (missingRequired?.length || + lackingCharLength?.length || + regexPatternMismatch?.length || + regexRestrictPatternMatch?.length || + invalidRange?.length) ) { return Promise.resolve({ err: "VALIDATION_ERROR", @@ -507,7 +514,7 @@ export function saveItem(itemZUID, action = "") { }; } -export function createItem(modelZUID, itemZUID) { +export function createItem({ modelZUID, itemZUID, skipPathPartValidation }) { return (dispatch, getState) => { const state = getState(); @@ -536,9 +543,16 @@ export function createItem(modelZUID, itemZUID) { item.meta.createdByUserZUID = state.user.user_zuid; } - // Check required fields are not empty + // Check required fields are not empty, except the og and tc fields since these + // are handled by the meta component const missingRequired = fields.filter((field) => { - if (!field.deletedAt && field.required) { + if ( + !field.deletedAt && + !["og_title", "og_description", "tc_title", "tc_description"].includes( + field.name + ) && + field.required + ) { if (!item.data[field.name] && item.data[field.name] != 0) { return true; } @@ -546,6 +560,12 @@ export function createItem(modelZUID, itemZUID) { return false; }); + const hasMissingRequiredSEOFields = skipPathPartValidation + ? !item?.web?.metaTitle + : !item?.web?.metaTitle || + !item?.web?.metaDescription || + !item?.web?.pathPart; + // Check minlength is satisfied const lackingCharLength = fields?.filter( (field) => @@ -583,7 +603,8 @@ export function createItem(modelZUID, itemZUID) { lackingCharLength?.length || regexPatternMismatch?.length || regexRestrictPatternMatch?.length || - invalidRange?.length + invalidRange?.length || + hasMissingRequiredSEOFields ) { return Promise.resolve({ err: "VALIDATION_ERROR", diff --git a/src/shell/store/index.js b/src/shell/store/index.js index 0ceea53fbe..42dec2b778 100644 --- a/src/shell/store/index.js +++ b/src/shell/store/index.js @@ -39,6 +39,7 @@ import { mediaManagerApi } from "../services/mediaManager"; import { metricsApi } from "../services/metrics"; import { cloudFunctionsApi } from "../services/cloudFunctions"; import { marketingApi } from "../services/marketing"; +import { analyticsApi } from "../services/analytics"; // Middleware is applied in order of array const middlewares = [ @@ -56,6 +57,7 @@ const middlewares = [ metricsApi.middleware, cloudFunctionsApi.middleware, marketingApi.middleware, + analyticsApi.middleware, ]; /** @@ -122,6 +124,7 @@ function createReducer(asyncReducers) { [metricsApi.reducerPath]: metricsApi.reducer, [cloudFunctionsApi.reducerPath]: cloudFunctionsApi.reducer, [marketingApi.reducerPath]: marketingApi.reducer, + [analyticsApi.reducerPath]: analyticsApi.reducer, }; return combineReducers({ diff --git a/src/shell/store/media-revamp.ts b/src/shell/store/media-revamp.ts index f75d83af73..239303430b 100644 --- a/src/shell/store/media-revamp.ts +++ b/src/shell/store/media-revamp.ts @@ -27,12 +27,18 @@ export type UploadFile = { loading?: boolean; bin_id?: string; group_id?: string; + replacementFile?: boolean; }; type FileUploadStart = StoreFile & { file: File }; type FileUploadSuccess = StoreFile & FileBase & { id: string }; type FileUploadProgress = { uploadID: string; progress: number }; -type FileUploadStageArg = { file: File; bin_id: string; group_id: string }; +type FileUploadStageArg = { + file: File; + bin_id: string; + group_id: string; + replacementFile?: boolean; +}; type StagedUpload = { status: "staged"; @@ -146,6 +152,7 @@ const mediaSlice = createSlice({ uploadID: uuidv4(), url: URL.createObjectURL(file.file), filename: file.file.name, + replacementFile: file.replacementFile, ...file, }; }); @@ -334,11 +341,11 @@ type FileAugmentation = { group_id?: string; }; -async function getSignedUrl(file: any, bin: Bin) { +async function getSignedUrl(filename: string, storageName: string) { try { return request( //@ts-expect-error - `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${bin.storage_name}/${file.file.name}` + `${CONFIG.SERVICE_MEDIA_STORAGE}/signed-url/${storageName}/${filename}` ).then((res) => res.data.url); } catch (err) { console.error(err); @@ -349,6 +356,152 @@ async function getSignedUrl(file: any, bin: Bin) { } } +export function replaceFile(newFile: UploadFile, originalFile: FileBase) { + return async (dispatch: Dispatch, getState: () => AppState) => { + const bodyData = new FormData(); + const req = new XMLHttpRequest(); + const file = { + progress: 0, + loading: true, + ...newFile, + }; + + bodyData.append("file", file.file, originalFile.filename); + bodyData.append("file_id", originalFile.id); + + req.upload.addEventListener("progress", function (e) { + file.progress = (e.loaded / e.total) * 100; + + dispatch(fileUploadProgress(file)); + }); + + function handleError() { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: "Failed uploading file", + kind: "error", + }) + ); + } + + req.addEventListener("abort", handleError); + req.addEventListener("error", handleError); + req.addEventListener("load", (_) => { + if (req.status === 200) { + dispatch( + notify({ + message: `File Replaced: ${originalFile.filename}`, + kind: "success", + }) + ); + const successFile = { + ...originalFile, + uploadID: file.uploadID, + progress: 100, + loading: false, + url: URL.createObjectURL(file.file), + }; + dispatch(fileUploadSuccess(successFile)); + } else { + dispatch( + notify({ + message: "Failed uploading file", + kind: "error", + }) + ); + dispatch(fileUploadError(file)); + } + }); + + // Use signed url flow for large files + if (file.file.size > 32000000) { + /** + * GAE has an inherent 32mb limit at their global nginx load balancer + * We use a signed url for large file uploads directly to the assocaited bucket + */ + + const signedUrl = await getSignedUrl( + originalFile?.filename, + originalFile?.storage_name + ); + req.open("PUT", signedUrl); + + // The sent content-type needs to match what was provided when generating the signed url + // @see https://medium.com/imersotechblog/upload-files-to-google-cloud-storage-gcs-from-the-browser-159810bb11e3 + req.setRequestHeader("Content-Type", file.file.type); + + req.addEventListener("load", () => { + if (req.status === 200) { + return request( + //@ts-expect-error + `${CONFIG.SERVICE_MEDIA_MANAGER}/file/${originalFile?.id}/purge?triggerUpdate=true`, + { + method: "POST", + json: true, + } + ) + .then((res) => { + if (res.status === 200) { + const state: State = getState().mediaRevamp; + if (state.uploads.length) { + dispatch( + fileUploadSuccess({ + ...res.data, + uploadID: file.uploadID, + }) + ); + } else { + dispatch( + notify({ + message: `Successfully uploaded file`, + kind: "success", + }) + ); + } + } else { + throw res; + } + }) + .catch((err) => { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: + "Failed creating file record after signed url upload", + kind: "error", + }) + ); + }); + } else { + dispatch(fileUploadError(file)); + dispatch( + notify({ + message: "Failed uploading file to signed url", + kind: "error", + }) + ); + } + }); + + // When sending directly to bucket it needs to be just the file + // and not the extra meta data for the zesty services + req.send(file.file); + } else { + req.withCredentials = true; + req.open( + "PUT", + //@ts-expect-error + `${CONFIG.SERVICE_MEDIA_STORAGE}/replace/${originalFile?.storage_driver}/${originalFile?.storage_name}` + ); + + req.send(bodyData); + } + + dispatch(fileUploadStart(file)); + }; +} + //type FileMonstrosity = {file: File } & FileAugmentation & FileBase export function uploadFile(fileArg: UploadFile, bin: Bin) { return async (dispatch: Dispatch, getState: () => AppState) => { @@ -417,7 +570,7 @@ export function uploadFile(fileArg: UploadFile, bin: Bin) { * We use a signed url for large file uploads directly to the assocaited bucket */ - const signedUrl = await getSignedUrl(file, bin); + const signedUrl = await getSignedUrl(file.file.name, bin.storage_name); req.open("PUT", signedUrl); // The sent content-type needs to match what was provided when generating the signed url @@ -596,16 +749,18 @@ export function dismissFileUploads() { ); } if (successfulUploads.length) { - dispatch( - notify({ - message: `Successfully uploaded ${successfulUploads.length} files${ - inProgressUploads.length - ? `...${inProgressUploads.length} files still in progress` - : "" - }`, - kind: "success", - }) - ); + if (!successfulUploads[0].replacementFile) { + dispatch( + notify({ + message: `Successfully uploaded ${successfulUploads.length} files${ + inProgressUploads.length + ? `...${inProgressUploads.length} files still in progress` + : "" + }`, + kind: "success", + }) + ); + } } if (failedUploads.length) { dispatch( @@ -622,6 +777,12 @@ export function dismissFileUploads() { kind: "warn", }) ); + } else { + successfulUploads?.forEach((upload) => { + dispatch( + mediaManagerApi.util.invalidateTags([{ type: "File", id: upload.id }]) + ); + }); } dispatch(fileUploadReset()); }; diff --git a/src/shell/store/notifications.ts b/src/shell/store/notifications.ts index 271ac555b4..7c6e89514d 100644 --- a/src/shell/store/notifications.ts +++ b/src/shell/store/notifications.ts @@ -26,7 +26,7 @@ type NotifyArgs = { kind: "warn" | "error" | "success"; HTML?: unknown; heading?: string; - message?: string; + message: string; }; type Notification = NotifyArgs & { diff --git a/src/shell/store/products.js b/src/shell/store/products.js index 6b5b9befd3..c0e5fece04 100644 --- a/src/shell/store/products.js +++ b/src/shell/store/products.js @@ -20,6 +20,7 @@ export function fetchProducts() { // seo: 31-71cfc74-s30 case "31-71cfc74-0wn3r": case "31-71cfc74-4dm13": + case "31-71cfc74-4cc4dm13": data = [ "launchpad", "content", @@ -35,6 +36,7 @@ export function fetchProducts() { ]; break; case "31-71cfc74-d3v3l0p3r": + case "31-71cfc74-d3vc0n": data = [ "launchpad", "content", diff --git a/src/shell/store/types.ts b/src/shell/store/types.ts index b3c678b2e7..268bf7dc07 100644 --- a/src/shell/store/types.ts +++ b/src/shell/store/types.ts @@ -1,6 +1,10 @@ import { UIState } from "./ui"; import { State as MediaRevampState } from "./media-revamp"; -import { InstalledApp, ModelType } from "../services/types"; +import { + InstalledApp, + ModelType, + ContentItemWithDirtyAndPublishing, +} from "../services/types"; /* TODO The UI state is well typed but the rest of the application state is entirely @@ -32,7 +36,7 @@ export type AppState = { languages: any; models: any; fields: any; - content: any; + content: Record; contentVersions: any; mediaRevamp: MediaRevampState; media: any; diff --git a/src/utility/getFlagEmoji.ts b/src/utility/getFlagEmoji.ts new file mode 100644 index 0000000000..dd4b9854ec --- /dev/null +++ b/src/utility/getFlagEmoji.ts @@ -0,0 +1,9 @@ +export default (countryCode: string) => { + // Convert country code to flag emoji. + // Unicode flag emojis are made up of regional indicator symbols, which are a sequence of two letters. + const baseOffset = 0x1f1e6; + return ( + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(0) - 65)) + + String.fromCodePoint(baseOffset + (countryCode.charCodeAt(1) - 65)) + ); +};