diff --git a/package-lock.json b/package-lock.json index 7782eacf..c95fa9c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1625,6 +1625,27 @@ "react": "^16.8 || ^17.0 || ^18.0" } }, + "node_modules/@aws-amplify/ui-react/node_modules/@radix-ui/react-tabs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.0.tgz", + "integrity": "sha512-oKUwEDsySVC0uuSEH7SHCVt1+ijmiDFAI9p+fHCtuZdqrRDKIFs09zp5nrmu4ggP6xqSx9lj1VSblnDH+n3IBA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.0", + "@radix-ui/react-context": "1.0.0", + "@radix-ui/react-direction": "1.0.0", + "@radix-ui/react-id": "1.0.0", + "@radix-ui/react-presence": "1.0.0", + "@radix-ui/react-primitive": "1.0.0", + "@radix-ui/react-roving-focus": "1.0.0", + "@radix-ui/react-use-controllable-state": "1.0.0" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, "node_modules/@aws-amplify/ui-react/node_modules/@radix-ui/react-use-callback-ref": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", @@ -13855,55 +13876,302 @@ } }, "node_modules/@radix-ui/react-tabs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.0.tgz", - "integrity": "sha512-oKUwEDsySVC0uuSEH7SHCVt1+ijmiDFAI9p+fHCtuZdqrRDKIFs09zp5nrmu4ggP6xqSx9lj1VSblnDH+n3IBA==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.1.tgz", + "integrity": "sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-presence": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-roving-focus": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-presence": "1.1.1", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-roving-focus": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz", + "integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.0.tgz", + "integrity": "sha512-GZsZslMJEyo1VKm5L1ZJY8tGDxZNPAoUeQUIbKeJfoi7Q4kmig5AsgLMYYuyYbfjd8fBmFORAIwYAkXMnXZgZw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-direction": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", + "integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-id": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz", + "integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } } }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.0.tgz", - "integrity": "sha512-lHvO4MhvoWpeNbiJAoyDsEtbKqP2jkkdwsMVJ3kfqbkC71J/aXE6Th6gkZA1xHEqSku+t+UgoDjvE7Z3gsBpcg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", + "integrity": "sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.0", - "@radix-ui/react-collection": "1.0.0", - "@radix-ui/react-compose-refs": "1.0.0", - "@radix-ui/react-context": "1.0.0", - "@radix-ui/react-direction": "1.0.0", - "@radix-ui/react-id": "1.0.0", - "@radix-ui/react-primitive": "1.0.0", - "@radix-ui/react-use-callback-ref": "1.0.0", - "@radix-ui/react-use-controllable-state": "1.0.0" + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-collection": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-controllable-state": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-callback-ref": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", - "integrity": "sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", + "integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz", + "integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.13.10" + "@radix-ui/react-use-callback-ref": "1.1.0" }, "peerDependencies": { - "react": "^16.8 || ^17.0 || ^18.0" + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz", + "integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/@radix-ui/react-toast": { @@ -14782,48 +15050,6 @@ } } }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-primitive": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", @@ -14880,36 +15106,6 @@ } } }, - "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-tabs": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", - "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", - "dependencies": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-roving-focus": "1.0.4", - "@radix-ui/react-use-controllable-state": "1.0.1" - }, - "peerDependencies": { - "@types/react": "*", - "@types/react-dom": "*", - "react": "^16.8 || ^17.0 || ^18.0", - "react-dom": "^16.8 || ^17.0 || ^18.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - } - } - }, "node_modules/@radix-ui/themes/node_modules/@radix-ui/react-use-controllable-state": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", diff --git a/src/api/buildQuery.js b/src/api/buildQuery.js index 9741d1d6..809afa9f 100644 --- a/src/api/buildQuery.js +++ b/src/api/buildQuery.js @@ -60,6 +60,7 @@ const imageFields = ` comments { ${imageCommentFields} } + tags reviewed `; @@ -152,6 +153,12 @@ const projectLabelFields = ` ml `; +const projectTagFields = ` + _id + name + color +`; + const projectFields = ` _id name @@ -169,6 +176,9 @@ const projectFields = ` labels { ${projectLabelFields} } + tags { + ${projectTagFields} + } availableMLModels `; @@ -544,6 +554,45 @@ const queries = { variables: { input: input }, }), + createProjectTag: (input) => ({ + template: ` + mutation CreateProjectTag($input: CreateProjectTagInput!) { + createProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + + deleteProjectTag: (input) => ({ + template: ` + mutation DeleteProjectTag($input: DeleteProjectTagInput!) { + deleteProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + + updateProjectTag: (input) => ({ + template: ` + mutation UpdateProjectTag($input: UpdateProjectTagInput!) { + updateProjectTag(input: $input) { + tags { + ${projectTagFields} + } + } + } + `, + variables: { input: input }, + }), + createProjectLabel: (input) => ({ template: ` mutation CreateProjectLabel($input: CreateProjectLabelInput!) { @@ -619,6 +668,28 @@ const queries = { `, variables: { input: input }, }), + + createImageTag: (input) => ({ + template: ` + mutation CreateImageTag($input: CreateImageTagInput!) { + createImageTag(input: $input) { + tags + } + } + `, + variables: { input: input }, + }), + + deleteImageTag: (input) => ({ + template: ` + mutation DeleteImageTag($input: DeleteImageTagInput!) { + deleteImageTag(input: $input) { + tags + } + } + `, + variables: { input: input }, + }), createDeployment: (input) => ({ template: ` diff --git a/src/assets/fontawesome.js b/src/assets/fontawesome.js index 1821266c..7d9536a4 100644 --- a/src/assets/fontawesome.js +++ b/src/assets/fontawesome.js @@ -29,7 +29,8 @@ import { faUpload, faUser, faTag, - faRetweet + faRetweet, + faHighlighter } from '@fortawesome/free-solid-svg-icons'; library.add( @@ -62,5 +63,6 @@ library.add( faUpload, faUser, faTag, - faRetweet + faRetweet, + faHighlighter ); diff --git a/src/components/ErrorToast.jsx b/src/components/ErrorToast.jsx index a0853853..b450a27c 100644 --- a/src/components/ErrorToast.jsx +++ b/src/components/ErrorToast.jsx @@ -8,6 +8,8 @@ import { dismissLabelsError, selectCommentsErrors, dismissCommentsError, + selectTagsErrors, + dismissTagsError, } from '../features/review/reviewSlice'; import { selectProjectsErrors, @@ -20,6 +22,8 @@ import { dismissCreateProjectError, selectManageLabelsErrors, dismissManageLabelsError, + selectProjectTagErrors, + dismissProjectTagErrors } from '../features/projects/projectsSlice'; import { selectWirelessCamerasErrors, @@ -59,6 +63,7 @@ import { selectManageUserErrors, dismissManageUsersError } from '../features/pro const ErrorToast = () => { const dispatch = useDispatch(); const labelsErrors = useSelector(selectLabelsErrors); + const tagsErrors = useSelector(selectTagsErrors); const commentsErrors = useSelector(selectCommentsErrors); const projectsErrors = useSelector(selectProjectsErrors); const viewsErrors = useSelector(selectViewsErrors); @@ -76,10 +81,13 @@ const ErrorToast = () => { const manageLabelsErrors = useSelector(selectManageLabelsErrors); const uploadErrors = useSelector(selectUploadErrors); const cameraSerialNumberErrors = useSelector(selectCameraSerialNumberErrors); + const projectTagErrors = useSelector(selectProjectTagErrors); const deleteImagesErrors = useSelector(selectDeleteImagesErrors); const enrichedErrors = [ enrichErrors(labelsErrors, 'Label Error', 'labels'), + enrichErrors(tagsErrors, 'Tag Error', 'tags'), + enrichErrors(projectTagErrors, 'Tag Error', 'projectTags'), enrichErrors(commentsErrors, 'Comment Error', 'comments'), enrichErrors(projectsErrors, 'Project Error', 'projects'), enrichErrors(viewsErrors, 'View Error', 'views'), @@ -148,6 +156,8 @@ const ErrorToast = () => { const dismissErrorActions = { labels: (i) => dismissLabelsError(i), + tags: (i) => dismissTagsError(i), + projectTags: (i) => dismissProjectTagErrors(i), comments: (i) => dismissCommentsError(i), projects: (i) => dismissProjectsError(i), createProject: (i) => dismissCreateProjectError(i), diff --git a/src/components/HydratedModal.jsx b/src/components/HydratedModal.jsx index 13efd114..900a9c8c 100644 --- a/src/components/HydratedModal.jsx +++ b/src/components/HydratedModal.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import moment from 'moment-timezone'; import { Modal } from './Modal.jsx'; @@ -9,7 +9,6 @@ import AutomationRulesForm from '../features/projects/AutomationRulesForm.jsx'; import SaveViewForm from '../features/projects/SaveViewForm.jsx'; import DeleteViewForm from '../features/projects/DeleteViewForm.jsx'; import ManageUsersModal from '../features/projects/ManageUsersModal.jsx'; -import ManageLabelsModal from '../features/projects/ManageLabelsModal/index.jsx'; import BulkUploadForm from '../features/upload/BulkUploadForm.jsx'; import UpdateCameraSerialNumberForm from '../features/cameras/UpdateCameraSerialNumberForm.jsx'; import { @@ -33,6 +32,7 @@ import { setSelectedCamera, } from '../features/projects/projectsSlice'; import { clearUsers } from '../features/projects/usersSlice.js'; +import { ManageLabelsAndTagsModal, ManageLabelsAndTagsModalTitle } from '../features/projects/ManageTagsAndLabelsModal.jsx'; // Modal populated with content const HydratedModal = () => { @@ -55,6 +55,8 @@ const HydratedModal = () => { cameraSerialNumberLoading.isLoading || deleteImagesLoading.isLoading; + const [manageTagsAndLabelsTab, setManageTagsAndLabelsTab] = useState("labels"); + const modalContentMap = { 'stats-modal': { title: 'Stats', @@ -109,12 +111,6 @@ const HydratedModal = () => { content: , callBackOnClose: () => dispatch(clearUsers()), }, - 'manage-labels-form': { - title: 'Manage labels', - size: 'md', - content: , - callBackOnClose: () => true, - }, 'update-serial-number-form': { title: 'Edit Camera Serial Number', size: 'md', @@ -124,6 +120,15 @@ const HydratedModal = () => { dispatch(clearCameraSerialNumberTask()); }, }, + 'manage-tags-and-labels-form': { + title: , + size: 'md', + content: , + callBackOnClose: () => { + setManageTagsAndLabelsTab("labels"); + return true; + }, + } }; const handleModalToggle = (content) => { diff --git a/src/components/PanelHeader.jsx b/src/components/PanelHeader.jsx index b6a635dd..7a5cfba8 100644 --- a/src/components/PanelHeader.jsx +++ b/src/components/PanelHeader.jsx @@ -5,6 +5,7 @@ import IconButton from './IconButton'; const PanelTitle = styled('span', { // marginLeft: '$2', + flex: '1' }); const ClosePanelButton = styled(IconButton, { diff --git a/src/components/TagSelector.jsx b/src/components/TagSelector.jsx new file mode 100644 index 00000000..318ac0da --- /dev/null +++ b/src/components/TagSelector.jsx @@ -0,0 +1,217 @@ +import React, { useEffect, useState } from 'react'; +import { styled } from '../theme/stitches.config.js'; +// [FUTURE FEATURE] +// import { Cross2Icon, MagnifyingGlassIcon } from '@radix-ui/react-icons'; +import { PlusCircledIcon } from '@radix-ui/react-icons'; +import { + Root as PopoverRoot, + PopoverTrigger, + PopoverContent, + PopoverPortal, +} from '@radix-ui/react-popover'; +import { mauve, violet } from '@radix-ui/colors'; + +const Selector = styled('div', { + padding: '$1 $3', + fontSize: '$2', + color: mauve.mauve11, + fontWeight: 'bold', + display: 'grid', + placeItems: 'center', + height: 32, + borderRadius: '$2', + borderColor: '$gray10', + borderStyle: 'dashed', + borderWidth: '1px', + '&:hover': { + cursor: 'pointer', + background: violet.violet3, + }, +}); + +const SelectorTitle = styled('div', { + display: 'flex', + gap: '$2', +}); + +const TagSelectorContent = styled('div', { + background: 'White', + border: '1px solid $border', + borderRadius: '$2', + boxShadow: 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px', + overflow: 'hidden', + fontFamily: '$mono', + fontSize: '$3', + fontWeight: '$1', +}); + +// [FUTURE FEATURE] +// const TagSearchContainer = styled('div', { +// display: 'flex', +// borderTop: '1px solid $border', +// paddingBottom: '$1', +// paddingTop: '$1', +// }); +// +// const TagSearchIcon = styled('div', { +// width: 32, +// display: 'grid', +// placeItems: 'center', +// }); +// +// const CrossIcon = styled(Cross2Icon, { +// '&:hover': { +// cursor: 'pointer' +// } +// }); +// +// const TagSearch = styled('input', { +// all: 'unset', +// padding: '$1 $3', +// paddingLeft: 'unset' +// }); + +const TagOptionsContainer = styled('div', { + maxHeight: '50vh', + overflowY: 'auto', + maxWidth: 450, +}); + +const TagOption = styled('div', { + padding: '$1 $3', + '&:hover': { + background: '$gray3', + cursor: 'pointer', + }, +}); + +const DefaultTagMessage = styled('div', { + padding: '$2 $3', + color: '$gray10', +}); + +// [FUTURE FEATURE] +// const filterList = (tags, searchTerm) => { +// return tags.filter(({ name }) => { +// const lower = name.toLowerCase(); +// const searchLower = searchTerm.toLowerCase(); +// return lower.startsWith(searchLower) +// }); +// } +// +// const allTagsAdded = "All tags added" +// const noMatches = "No matches" + +export const TagSelector = ({ projectTags, unaddedTags, onAddTag, imageId }) => { + const [isOpen, setIsOpen] = useState(false); + const [tagOptions, setTagOptions] = useState(unaddedTags); + + // [FUTURE FEATURE] + // const errMessage = 'All tags added'; + // const [searchValue, setSearchValue] = useState(""); + // const [errMessage, setErrMessage] = useState(allTagsAdded); + + // [FUTURE FEATURE] + // const onInput = (e) => { + // if (e.target.value !== undefined) { + // setSearchValue(e.target.value); + // } + // } + // + // useEffect(() => { + // const options = filterList(tagList, searchValue); + // if (searchValue === "") { + // setErrMessage(allTagsAdded) + // } else if (options.length === 0) { + // setErrMessage(noMatches); + // } + // setTagOptions(options); + // }, [searchValue]); + // + // [FUTURE FEATURE] Leave search commented for now + // const onClearSearch = () => { + // setSearchValue(""); + // setErrMessage(allTagsAdded) + // } + + const onClickTag = (tag) => { + const options = tagOptions.filter(({ _id }) => _id !== tag._id); + setTagOptions(options); + onAddTag(tag); + }; + + // Close when changing image using keyboard nav + useEffect(() => { + setIsOpen(false); + }, [imageId]); + + return ( + + setTagOptions(unaddedTags)}> + + + + Tag + + + + + + + + {projectTags.length === 0 && ( + + No{' '} + + tags + {' '} + available. To add tags to your Project, select the "Manage labels and + tags" button in the sidebar. + + )} + {projectTags.length > 0 && tagOptions.length === 0 && ( + All tags added + )} + {tagOptions.map((tag) => ( + onClickTag(tag)}> + {tag.name} + + ))} + + {/* [FUTURE FEATURE] + + + { searchValue !== "" && + onClearSearch()} + height={18} + width={18} + color={slate.slate9} + /> + } + { searchValue === "" && + + } + + { e.stopPropagation(); }} + onChange={(e) => onInput(e)} + /> + + */} + + + + + ); +}; diff --git a/src/features/loupe/ImageTag.jsx b/src/features/loupe/ImageTag.jsx new file mode 100644 index 00000000..2913be4d --- /dev/null +++ b/src/features/loupe/ImageTag.jsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { styled } from '../../theme/stitches.config'; +import { + Tooltip, + TooltipContent, + TooltipArrow, + TooltipTrigger, +} from '../../components/Tooltip.jsx'; +import { Cross2Icon } from '@radix-ui/react-icons'; +import Button from '../../components/Button.jsx'; +import { violet, mauve } from '@radix-ui/colors'; + +export const itemStyles = { + all: 'unset', + flex: '0 0 auto', + color: mauve.mauve11, + height: 30, + padding: '0 5px', + borderRadius: '0 4px 4px 0', + display: 'flex', + fontSize: 13, + lineHeight: 1, + alignItems: 'center', + justifyContent: 'center', + '&:hover': { + backgroundColor: violet.violet3, + color: violet.violet11, + cursor: 'pointer', + }, + '&:focus': { position: 'relative', boxShadow: `0 0 0 2px ${violet.violet7}` }, +}; + +const ToolbarIconButton = styled(Button, { + ...itemStyles, + backgroundColor: 'white', + '&:first-child': { marginLeft: 0 }, + '&[data-state=on]': { + backgroundColor: violet.violet5, + color: violet.violet11, + }, + svg: { + marginRight: '$1', + marginLeft: '$1', + }, +}); + +const TagContainer = styled('div', { + display: 'flex', + border: '1px solid rgba(0,0,0,0)', + borderRadius: 4, + height: 32, +}); + +const TagName = styled('div', { + padding: '$1 $3', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + marginLeft: '0', + marginRight: 'auto', + height: 30, + borderRadius: '4px 0 0 4px' +}); + +export const ImageTag = ({ + id, + name, + color, + onDelete +}) => { + return ( + + + { name } + + + + onDelete(id)}> + + + + + Delete tag + + + + + ); +} diff --git a/src/features/loupe/ImageTagsToolbar.jsx b/src/features/loupe/ImageTagsToolbar.jsx new file mode 100644 index 00000000..5a6f2921 --- /dev/null +++ b/src/features/loupe/ImageTagsToolbar.jsx @@ -0,0 +1,133 @@ +import React, { useEffect, useState } from 'react'; +import { styled } from '../../theme/stitches.config'; +import { mauve } from '@radix-ui/colors'; +import { ImageTag } from './ImageTag.jsx'; +import { TagSelector } from '../../components/TagSelector.jsx'; +import { editTag } from '../review/reviewSlice.js'; +import { useDispatch } from 'react-redux'; + +const Toolbar = styled('div', { + display: 'flex', + height: 'calc(32px + $2 + $2)', + width: '100%', + borderBottom: '1px solid $border', + position: 'relative', +}); + +const TagsContainer = styled('div', { + position: 'relative', + width: '100%', +}); + +const TagSelectorContainer = styled('div', { + margin: '$2', + marginRight: '0', + display: 'grid', + placeItems: 'center', +}); + +const ScrollContainer = styled('div', { + position: 'absolute', + width: '100%', + height: '48px', + display: 'flex', + gap: '$2', + overflowX: 'scroll', + whiteSpace: 'nowrap', + left: 0, + top: 0, + padding: '$2 0', + scrollbarWidth: 'none', +}); + +const Separator = styled('div', { + width: '1px', + backgroundColor: mauve.mauve6, + margin: '$2 10px', +}); + +const getImageTagInfo = (imageTags, projectTags) => { + return projectTags.filter((t) => { + return imageTags.find((it) => it === t._id) !== undefined; + }); +}; + +const getUnaddedTags = (imageTags, projectTags) => { + return projectTags.filter((t) => imageTags.findIndex((it) => it === t._id) === -1); +}; + +// Sort alphabetically with JS magic +const orderUnaddedTags = (unaddedTags) => { + return unaddedTags.sort((a, b) => a.name.localeCompare(b.name)); +}; + +export const ImageTagsToolbar = ({ image, projectTags }) => { + const dispatch = useDispatch(); + + const [imageTags, setImageTags] = useState(getImageTagInfo(image.tags, projectTags)); + const [unaddedTags, setUnaddedTags] = useState( + orderUnaddedTags(getUnaddedTags(image.tags, projectTags)), + ); + + // image._id -> when the enlarged image changes + // projectTags -> so that newly added project tags show up without refreshing + useEffect(() => { + setImageTags(getImageTagInfo(image.tags, projectTags)); + setUnaddedTags(orderUnaddedTags(getUnaddedTags(image.tags, projectTags))); + }, [image._id, projectTags]); + + const onDeleteTag = (tagId) => { + const deleteTagDto = { + tagId: tagId, + imageId: image._id, + }; + const idx = imageTags.findIndex((t) => t._id === tagId); + if (idx >= 0) { + const removed = imageTags.splice(idx, 1); + setImageTags([...imageTags]); + setUnaddedTags(orderUnaddedTags([...unaddedTags, ...removed])); + } + dispatch(editTag('delete', deleteTagDto)); + }; + + const onAddTag = (tag) => { + const addTagDto = { + tagId: tag._id, + imageId: image._id, + }; + const idx = unaddedTags.findIndex((t) => t._id === tag._id); + if (idx >= 0) { + setImageTags([...imageTags, tag]); + unaddedTags.splice(idx, 1); + setUnaddedTags(orderUnaddedTags([...unaddedTags])); + } + dispatch(editTag('create', addTagDto)); + }; + + return ( + + + + + + + + {imageTags.map(({ _id, name, color }) => ( + onDeleteTag(tagId)} + /> + ))} + + + + ); +}; diff --git a/src/features/loupe/Loupe.jsx b/src/features/loupe/Loupe.jsx index 26772e0a..71e8f57f 100644 --- a/src/features/loupe/Loupe.jsx +++ b/src/features/loupe/Loupe.jsx @@ -15,7 +15,7 @@ import { incrementImage, incrementFocusIndex, } from '../review/reviewSlice.js'; -import { selectModalOpen } from '../projects/projectsSlice.js'; +import { selectModalOpen, selectProjectTags } from '../projects/projectsSlice.js'; import { toggleOpenLoupe, selectReviewMode, selectIsAddingLabel, drawBboxStart, addLabelStart } from './loupeSlice.js'; import { selectUserUsername, selectUserCurrentRoles } from '../auth/authSlice'; import { hasRole, WRITE_OBJECTS_ROLES } from '../auth/roles.js'; @@ -24,6 +24,7 @@ import FullSizeImage from './FullSizeImage.jsx'; import ImageReviewToolbar from './ImageReviewToolbar.jsx'; import ShareImageButton from './ShareImageButton'; import LoupeDropdown from './LoupeDropdown.jsx'; +import { ImageTagsToolbar } from './ImageTagsToolbar.jsx'; const ItemValue = styled('div', { fontSize: '$3', @@ -79,7 +80,7 @@ const LoupeBody = styled('div', { // $7 - height of panel header // $8 - height of nav bar // 98px - height of toolbar plus height of 2 borders - height: 'calc(100vh - $7 - $8 - 98px)', + height: 'calc(100vh - $7 - $8 - 145px)', backgroundColor: '$hiContrast', }); @@ -99,7 +100,7 @@ const StyledLoupe = styled('div', { }); const ToolbarContainer = styled('div', { - height: '97px', + height: '145px', }); const ShareImage = styled('div', { @@ -118,6 +119,7 @@ const Loupe = () => { const focusIndex = useSelector(selectFocusIndex); const image = workingImages[focusIndex.image]; const dispatch = useDispatch(); + const projectTags = useSelector(selectProjectTags); // // track reivew mode // const reviewMode = useSelector(selectReviewMode); @@ -338,16 +340,22 @@ const Loupe = () => { {/**/} {image && hasRole(userRoles, WRITE_OBJECTS_ROLES) && ( - + <> + + + )} diff --git a/src/features/projects/ManageTagsAndLabelsModal.jsx b/src/features/projects/ManageTagsAndLabelsModal.jsx new file mode 100644 index 00000000..b58f53bc --- /dev/null +++ b/src/features/projects/ManageTagsAndLabelsModal.jsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { styled } from '../../theme/stitches.config'; +import ManageLabelsModal from './ManageLabelsModal'; +import { ManageTagsModal } from './ManageTagsModal/ManageTagsModal'; +import InfoIcon from '../../components/InfoIcon'; + +export const ManageLabelsAndTagsModal = ({ tab = 'labels' }) => { + return ( + <> + {tab === 'labels' && } + {tab === 'tags' && } + + ); +}; + +const TitleContainer = styled('div', { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + gap: '$2', +}); + +const TabTitle = styled('div', { + width: 'fit-content', + padding: '$1 $3', + borderRadius: '$4', + fontSize: '$3', + transition: 'all 40ms linear', + '&:hover': { + background: '$gray4', + cursor: 'pointer', + }, + '&:focus': { + background: '$gray4', + color: '$blue500', + }, + variants: { + active: { + true: { + background: '$hiContrast', + color: '$loContrast', + '&:hover': { + background: '$hiContrast', + cursor: 'pointer', + }, + }, + }, + }, +}); + +const ModalTitle = styled('div', { + position: 'absolute', + left: '$3', + paddingTop: '$1', + paddingBottom: '$1', +}); + +const TagsVsLabelsContent = styled('div', { + maxWidth: '300px', +}); + +const TagsVsLabelsHelp = () => ( + +

+ + Labels + {' '} + are used to describe an Object within an image (e.g., “animal”, “rodent”, + “sasquatch“) and can be applied by either AI or humans. +

+

+ + Tags + {' '} + are used to annotate the image as a whole (e.g., “favorite”, “seen”, “predation + event”), and can only be applied to an image by human reviewers. +

+
+); + +export const ManageLabelsAndTagsModalTitle = ({ tab, setTab }) => { + return ( + + {`Manage ${tab}`} + setTab('labels')}> + Labels + + setTab('tags')}> + Tags + + } /> + + ); +}; diff --git a/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx new file mode 100644 index 00000000..bf8ae159 --- /dev/null +++ b/src/features/projects/ManageTagsModal/DeleteTagAlert.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { Alert, AlertPortal, AlertOverlay, AlertContent, AlertTitle } from '../../../components/AlertDialog.jsx'; +import Button from '../../../components/Button.jsx'; +import { red } from '@radix-ui/colors'; +import { styled } from '../../../theme/stitches.config.js'; + +const PreviewTag = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + margin: 'auto $1', + height: '$5' +}); + +export const DeleteTagAlert = ({ + open, + tag, + onConfirm, + onCancel +}) => { + return ( + + + + + + Are you sure you'd like to delete the{' '} + {tag && + + { tag.name } + + } tag? + +
+ Deleting this tag will: +
    +
  • + remove it as an option to apply to your images +
  • +
  • remove all instances of it from your existing images
  • +
+ This action can not be undone. +
+
+ + +
+
+
+
+ ); +}; diff --git a/src/features/projects/ManageTagsModal/EditTag.jsx b/src/features/projects/ManageTagsModal/EditTag.jsx new file mode 100644 index 00000000..41d7bcc1 --- /dev/null +++ b/src/features/projects/ManageTagsModal/EditTag.jsx @@ -0,0 +1,332 @@ +import React, { useEffect, useState } from 'react'; +import { SymbolIcon } from '@radix-ui/react-icons'; +import IconButton from '../../../components/IconButton'; +import { styled } from '../../../theme/stitches.config'; +import { ColorPicker } from '../ManageLabelsModal/components'; +import { Tooltip, TooltipArrow, TooltipTrigger, TooltipContent } from '../../../components/Tooltip'; +import { getRandomColor, getTextColor } from '../../../app/utils'; +import * as Yup from 'yup'; +import Button from '../../../components/Button'; + +const defaultColors = [ + '#E54D2E', + '#E5484D', + '#E93D82', + '#D6409F', + '#AB4ABA', + '#8E4EC6', + '#6E56CF', + '#5B5BD6', + '#3E63DD', + '#0090FF', + '#00A2C7', + '#12A594', + '#30A46C', + '#46A758', + '#A18072', + '#F76B15', + '#FFC53D', + '#FFE629', + '#BDEE63', + '#7CE2FE', + '#8D8D8D', + '#F0F0F0', +]; + +const ColorSwatch = styled('button', { + border: 'none', + color: '$backgroundLight', + height: '$4', + width: '$4', + margin: 2, + borderRadius: '$2', + '&:hover': { + cursor: 'pointer', + }, +}); + +const createTagNameSchema = (currentName, allNames) => { + return Yup.string() + .required('Enter a tag name.') + .matches(/^[a-zA-Z0-9_. -]*$/, "Tags can't contain special characters") + .test('unique', 'A tag with this name already exists.', (val) => { + const allNamesLowerCase = allNames.map((n) => n.toLowerCase()) + if (val?.toLowerCase() === currentName.toLowerCase()) { + // name hasn't changed + return true; + } else if (!allNamesLowerCase.includes(val?.toLowerCase())) { + // name hasn't already been used + return true; + } else { + return false; + } + }); +} + +const tagColorSchema = Yup.string() + .matches(/^#[0-9A-Fa-f]{6}$/, { message: 'Enter a valid color code with 6 digits' }) + .required('Select a color.'); + +const EditContainer = styled('div', { + display: 'grid', + gridTemplateColumns: '1fr 1fr 1fr', + columnGap: '$3', + rowGap: '$1', + marginBottom: '$3', + marginTop: '$2', + '& > *': { + minWidth: 0 + } +}); + +const EditFieldLabel = styled('label', { + fontWeight: 'bold', + color: '$textDark', + fontSize: '$3' +}); + +const EditFieldInput = styled('input', { + padding: '$2 $3', + color: '$textMedium', + fontFamily: '$sourceSansPro', + border: '1px solid $border', + borderRadius: '$1', + minWidth: 0, + '&:focus': { + transition: 'all 0.2s ease', + outline: 'none', + boxShadow: '0 0 0 3px $gray3', + // borderColor: '$textDark', + '&:hover': { + boxShadow: '0 0 0 3px $blue200', + borderColor: '$blue500', + }, + }, +}); + +const EditFieldError = styled('div', { + color: '$errorText', + fontSize: '$3' +}); + +const EditActionButtonsContainer = styled('div', { + display: 'flex', + margin: 'auto 0', + gap: '$2', + justifyContent: 'flex-end' +}); + +const PreviewTagContainer = styled('div', { + display: 'flex', + marginTop: '$2' +}); + +const PreviewTag = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center', + marginLeft: '0', + marginRight: 'auto', + height: '$5' +}); + +export const EditTag = ({ + id, + currentName, + currentColor, + onPreviewColor, + allTagNames, + onSubmit, + onCancel, + isNewLabel, +}) => { + if (isNewLabel) { + currentColor = `#${getRandomColor()}` + currentName = '' + } + + // to get rid of warning for now + const [name, setName] = useState(currentName); + const [color, setColor] = useState(currentColor); + const [tempColor, setTempColor] = useState(currentColor); + + + const [nameError, setNameError] = useState(""); + const [colorError, setColorError] = useState(""); + + const updateColor = (newColor) => { + setTempColor(newColor); + setColor(newColor); + if (onPreviewColor) { + onPreviewColor(newColor); + } + setColorError(""); + } + + useEffect(() => { + if (colorError !== "") { + setColorError(""); + } + }, [tempColor, color]); + + useEffect(() => { + if (nameError !== "") { + setNameError(""); + } + }, [name]); + + const onCancelEdit = () => { + setName(currentName); + setColor(currentColor); + setTempColor(currentColor); + onCancel(); + } + + const onConfirmEdit = () => { + let validatedName = ""; + let validatedColor = ""; + + const tagNameSchema = createTagNameSchema(currentName, allTagNames); + + // If the user typed in a color, tempColor !== color + const submittedColor = tempColor !== color ? tempColor : color; + try { + validatedColor = tagColorSchema.validateSync(submittedColor); + } catch (err) { + setColorError(err.message); + } + + try { + validatedName = tagNameSchema.validateSync(name); + } catch (err) { + setNameError(err.message); + } + + if (validatedName === "" || validatedColor === "") { + return; + } + + if (isNewLabel) { + onSubmit(validatedName, validatedColor); + } else { + onSubmit(id, validatedName, validatedColor); + } + } + + return ( + <> + { isNewLabel && + + + { !name ? 'new tag' : name } + + + } + + {/* Row 1 column 1 */} + Name + + {/* Row 1 column 2 */} + Color + + {/* Row 1 column 3 */} +
+ + {/* Row 2 column 1 */} + setName(e.target.value)} + /> + + {/* Row 2 column 2 */} + + + + updateColor(`#${getRandomColor()}`)} + css={{ + backgroundColor: color, + borderColor: color, + color: getTextColor(color), + '&:hover': { + borderColor: color, + }, + '&:active': { + borderColor: '$border', + }, + }} + > + + + + + Get a new color + + + + + + setTempColor(`${e.target.value}`)}/> + + +
Choose from default colors:
+ {defaultColors.map((color) => ( + updateColor(color)} + /> + ))} + +
+
+
+ + {/* Row 2 column 3 */} + + + + + + {/* Row 3 column 1 */} + {nameError} + + {/* Row 3 column 2 */} + {colorError} + + + ); +} diff --git a/src/features/projects/ManageTagsModal/EditableTag.jsx b/src/features/projects/ManageTagsModal/EditableTag.jsx new file mode 100644 index 00000000..a06419a6 --- /dev/null +++ b/src/features/projects/ManageTagsModal/EditableTag.jsx @@ -0,0 +1,91 @@ +import React, { useState } from 'react'; +import { Pencil1Icon, TrashIcon } from '@radix-ui/react-icons'; +import IconButton from '../../../components/IconButton'; +import { styled } from '../../../theme/stitches.config'; +import { EditTag } from './EditTag'; + +const Container = styled('div', { + paddingTop: '$2', + borderBottom: '1px solid $border' +}); + +const Inner = styled('div', { + marginBottom: '$2', + display: 'flex', +}); + +const TagName = styled('div', { + padding: '$1 $3', + borderRadius: '$2', + border: '1px solid rgba(0,0,0,0)', + color: '$textDark', + fontFamily: '$mono', + fontWeight: 'bold', + fontSize: '$2', + display: 'grid', + placeItems: 'center' +}); + +const Actions = styled('div', { + marginRight: 0, + marginLeft: 'auto', + display: 'flex', + gap: '$3' +}); + +export const EditableTag = ({ + id, + currentName, + currentColor, + allTagNames, + onConfirmEdit, + onDelete +}) => { + const [isEditOpen, setIsEditOpen] = useState(false); + const [previewColor, setPreviewColor] = useState(currentColor); + + const onEdit = (newName, newColor) => { + onConfirmEdit(id, newName, newColor); + setIsEditOpen(false); + } + + return ( + + + + { currentName } + + + setIsEditOpen(true)} + > + + + onDelete(id)} + > + + + + + + {/* Tag edit form */} + { isEditOpen && + setPreviewColor(newColor)} + allTagNames={allTagNames} + onSubmit={(_id, newName, newColor) => onEdit(newName, newColor)} + onCancel={() => setIsEditOpen(false)} + /> + } + + ); +} diff --git a/src/features/projects/ManageTagsModal/ManageTagsModal.jsx b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx new file mode 100644 index 00000000..9774d501 --- /dev/null +++ b/src/features/projects/ManageTagsModal/ManageTagsModal.jsx @@ -0,0 +1,117 @@ +import React, { useState } from 'react'; +import { styled } from '../../../theme/stitches.config'; +import { EditableTag } from './EditableTag'; +import { EditTag } from './EditTag'; +import Button from '../../../components/Button'; +import { SimpleSpinner, SpinnerOverlay } from '../../../components/Spinner'; +import { DeleteTagAlert } from './DeleteTagAlert'; +import { useDispatch, useSelector } from 'react-redux'; +import { createProjectTag, deleteProjectTag, selectProjectTags, selectTagsLoading, updateProjectTag } from '../projectsSlice'; + +const EditableTagsContainer = styled('div', { + overflowY: 'scroll', + padding: '3px', // so that the input boxes' shadow does get cutoff + maxHeight: '500px' +}); + +const AddNewTagButtonContainer = styled('div', { + display: 'flex', +}); + +const AddNewTagButton = styled(Button, { + marginRight: 0, + marginLeft: 'auto', + marginTop: '$3' +}); + +const EditTagContainer = styled('div', { + marginLeft: '3px' +}); + + +export const ManageTagsModal = () => { + const dispatch = useDispatch(); + const tags = useSelector(selectProjectTags); + + const [isNewTagOpen, setIsNewTagOpen] = useState(false); + + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [tagToDelete, setTagToDelete] = useState(''); + + const isLoading = useSelector(selectTagsLoading); + + const onConfirmEdit = (tagId, tagName, tagColor) => { + console.log("edit", tagId, tagName, tagColor); + dispatch(updateProjectTag({ _id: tagId, name: tagName, color: tagColor })); + } + + const onConfirmAdd = (tagName, tagColor) => { + dispatch(createProjectTag({ name: tagName, color: tagColor })); + setIsNewTagOpen(false); + } + + const onConfirmDelete = (tagId) => { + dispatch(deleteProjectTag({ _id: tagId })); + setIsAlertOpen(false); + } + + const onStartDelete = (id) => { + setTagToDelete(id); + setIsAlertOpen(true); + } + + const onCancelDelete = () => { + setIsAlertOpen(false); + setTagToDelete(''); + } + + return ( + <> + {isLoading && ( + + + + )} + + { tags.map(({ _id, name, color }) => ( + tag.name)} + onConfirmEdit={onConfirmEdit} + onDelete={(id) => onStartDelete(id)} + /> + ))} + + { !isNewTagOpen && + + setIsNewTagOpen(true)} + > + New tag + + + } + { isNewTagOpen && + + t.name)} + onSubmit={onConfirmAdd} + onCancel={() => setIsNewTagOpen(false)} + isNewLabel={true} + /> + + } + tag._id === tagToDelete)} + onConfirm={onConfirmDelete} + onCancel={onCancelDelete} + /> + + ); +} diff --git a/src/features/projects/SidebarNav.jsx b/src/features/projects/SidebarNav.jsx index e45c571b..21013e52 100644 --- a/src/features/projects/SidebarNav.jsx +++ b/src/features/projects/SidebarNav.jsx @@ -121,16 +121,17 @@ const SidebarNav = ({ toggleFiltersPanel, filtersPanelOpen }) => { /> )} - {/* Manage label view */} + {/* Manage label and tag view */} {hasRole(userRoles, WRITE_PROJECT_ROLES) && ( handleModalToggle('manage-labels-form')} + handleClick={() => handleModalToggle('manage-tags-and-labels-form')} icon={} - tooltipContent="Manage labels" + tooltipContent="Manage labels and tags" /> )} + ); }; diff --git a/src/features/projects/projectsSlice.js b/src/features/projects/projectsSlice.js index 63cc83a6..b99d9965 100644 --- a/src/features/projects/projectsSlice.js +++ b/src/features/projects/projectsSlice.js @@ -53,6 +53,11 @@ const initialState = { operation: null, errors: null, }, + projectTags: { + isLoading: false, + operation: null, + errors: null + }, }, unsavedViewChanges: false, modalOpen: false, @@ -361,6 +366,77 @@ export const projectsSlice = createSlice({ state.loadingStates.projectLabels.errors.splice(index, 1); }, + /* + * Project Tags CRUD + */ + + createProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'creating', errors: null }; + state.loadingStates.projectTags = ls; + }, + + createProjectTagSuccess: (state, { payload }) => { + const ls = { + isLoading: false, + operation: null, + errors: null, + }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags; + }, + + createProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + + deleteProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'deleting', errors: null }; + state.loadingStates.projectTags = ls; + }, + + deleteProjectTagSuccess: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: null }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags + }, + + deleteProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + + updateProjectTagStart: (state) => { + const ls = { isLoading: true, operation: 'updating', errors: null }; + state.loadingStates.projectTags = ls; + }, + + updateProjectTagSuccess: (state, { payload }) => { + const ls = { + isLoading: false, + operation: null, + errors: null, + }; + state.loadingStates.projectTags = ls; + + const proj = state.projects.find((p) => p._id === payload.projId); + proj.tags = payload.tags + }, + + updateProjectTagFailure: (state, { payload }) => { + const ls = { isLoading: false, operation: null, errors: payload }; + state.loadingStates.projectTags = ls; + }, + + dismissProjectTagErrors: (state, { payload }) => { + const index = payload; + state.loadingStates.projectTags.errors.splice(index, 1); + }, + setModalOpen: (state, { payload }) => { state.modalOpen = payload; }, @@ -416,6 +492,7 @@ export const { setSelectedProjAndView, setUnsavedViewChanges, dismissProjectsError, + dismissProjectTagErrors, createProjectStart, createProjectSuccess, createProjectFailure, @@ -452,6 +529,16 @@ export const { deleteProjectLabelFailure, dismissManageLabelsError, + createProjectTagStart, + createProjectTagFailure, + createProjectTagSuccess, + deleteProjectTagStart, + deleteProjectTagFailure, + deleteProjectTagSuccess, + updateProjectTagStart, + updateProjectTagFailure, + updateProjectTagSuccess, + setModalOpen, setModalContent, setSelectedCamera, @@ -631,6 +718,85 @@ export const fetchModelOptions = () => { }; }; +// Project Tags thunks +export const createProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(createProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'createProjectTag', + input: payload, + }); + dispatch(createProjectTagSuccess({ projId, tags: res.createProjectTag.tags })); + } + } catch (err) { + console.log(`error attempting to create tag: `, err); + dispatch(createProjectTagFailure(err)); + } + }; +}; + +export const deleteProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(deleteProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'deleteProjectTag', + input: payload, + }); + dispatch(deleteProjectTagSuccess({ projId, tags: res.deleteProjectTag.tags })); + // TODO waterfall delete + // dispatch(clearImages()); + // dispatch(fetchProjects({ _ids: [projId] })); + } + } catch (err) { + console.log(`error attempting to delete tag: `, err); + dispatch(deleteProjectTagFailure(err)); + } + }; +}; + +export const updateProjectTag = (payload) => { + return async (dispatch, getState) => { + try { + dispatch(updateProjectTagStart()); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + const projId = selectedProj._id; + + if (token && selectedProj) { + const res = await call({ + projId, + request: 'updateProjectTag', + input: payload, + }); + dispatch(updateProjectTagSuccess({ projId, tags: res.updateProjectTag.tags })); + } + } catch (err) { + console.log(`error attempting to update tag: `, err); + dispatch(updateProjectTagFailure(err)); + } + }; +}; + // Project Labels thunks export const createProjectLabel = (payload) => { return async (dispatch, getState) => { @@ -729,6 +895,10 @@ export const selectMLModels = createSelector([selectSelectedProject], (proj) => export const selectLabels = createSelector([selectSelectedProject], (proj) => proj ? proj.labels : [], ); +export const selectProjectTags = createSelector([selectSelectedProject], (proj) => + proj ? proj.tags : [], +); +export const selectTagsLoading = (state) => state.projects.loadingStates.projectTags.isLoading; export const selectProjectsLoading = (state) => state.projects.loadingStates.projects; export const selectViewsLoading = (state) => state.projects.loadingStates.views; export const selectAutomationRulesLoading = (state) => state.projects.loadingStates.automationRules; @@ -751,5 +921,6 @@ export const selectModelOptionsLoading = (state) => export const selectProjectLabelsLoading = (state) => state.projects.loadingStates.projectLabels; export const selectManageLabelsErrors = (state) => state.projects.loadingStates.projectLabels.errors; +export const selectProjectTagErrors = (state) => state.projects.loadingStates.projectTags.errors; export default projectsSlice.reducer; diff --git a/src/features/review/reviewSlice.js b/src/features/review/reviewSlice.js index c8837514..19c3b116 100644 --- a/src/features/review/reviewSlice.js +++ b/src/features/review/reviewSlice.js @@ -25,6 +25,11 @@ const initialState = { operation: null /* 'fetching', 'updating', 'deleting' */, errors: null, }, + tags: { + isLoading: false, + operation: null /* 'fetching', 'deleting' */, + errors: null, + }, }, lastAction: null, lastCategoryApplied: null, @@ -177,11 +182,35 @@ export const reviewSlice = createSlice({ image.comments = payload.comments; }, + editTagStart: (state, { payload }) => { + state.loadingStates.tags.isLoading = true; + state.loadingStates.tags.operation = payload; + }, + + editTagFailure: (state, { payload }) => { + state.loadingStates.tags.isLoading = false; + state.loadingStates.tags.operation = null; + state.loadingStates.tags.errors = payload; + }, + + editTagSuccess: (state, { payload }) => { + state.loadingStates.tags.isLoading = false; + state.loadingStates.tags.operation = null; + state.loadingStates.tags.errors = null; + const image = findImage(state.workingImages, payload.imageId); + image.tags = payload.tags; + }, + dismissLabelsError: (state, { payload }) => { const index = payload; state.loadingStates.labels.errors.splice(index, 1); }, + dismissTagsError: (state, { payload }) => { + const index = payload; + state.loadingStates.tags.errors.splice(index, 1); + }, + dismissCommentsError: (state, { payload }) => { const index = payload; state.loadingStates.comments.errors.splice(index, 1); @@ -228,7 +257,11 @@ export const { editCommentStart, editCommentFailure, editCommentSuccess, + editTagStart, + editTagFailure, + editTagSuccess, dismissLabelsError, + dismissTagsError, dismissCommentsError, } = reviewSlice.actions; @@ -311,6 +344,44 @@ export const editComment = (operation, payload) => { }; }; +export const editTag = (operation, payload) => { + return async (dispatch, getState) => { + try { + console.log('editTag - operation: ', operation); + console.log('editTag - payload: ', payload); + + if (!operation || !payload) { + const msg = `An operation (create or delete) and payload is required`; + throw new Error(msg); + } + + dispatch(editTagStart(operation)); + const currentUser = await Auth.currentAuthenticatedUser(); + const token = currentUser.getSignInUserSession().getIdToken().getJwtToken(); + const projects = getState().projects.projects; + const selectedProj = projects.find((proj) => proj.selected); + + if (token && selectedProj) { + const req = `${operation}ImageTag`; + console.log('req:',req); + + const res = await call({ + projId: selectedProj._id, + request: req, + input: payload, + }); + console.log('editTag - res: ', res); + const mutation = Object.keys(res)[0]; + const tags = res[mutation].tags; + dispatch(editTagSuccess({ imageId: payload.imageId, tags })); + } + } catch (err) { + console.log(`error attempting to ${operation}ImageTag: `, err); + dispatch(editTagFailure(err)); + } + }; +}; + // Actions only used in middlewares: export const incrementFocusIndex = createAction('review/incrementFocusIndex'); export const incrementImage = createAction('review/incrementImage'); @@ -324,6 +395,8 @@ export const selectFocusChangeType = (state) => state.review.focusChangeType; export const selectLabelsErrors = (state) => state.review.loadingStates.labels.errors; export const selectCommentsErrors = (state) => state.review.loadingStates.comments.errors; export const selectCommentsLoading = (state) => state.review.loadingStates.comments.isLoading; +export const selectTagsErrors = (state) => state.review.loadingStates.tags.errors; +export const selectTagsLoading = (state) => state.review.loadingStates.comments.isLoading; export const selectLastAction = (state) => state.review.lastAction; export const selectLastCategoryApplied = (state) => state.review.lastCategoryApplied; export const selectSelectedImages = createSelector(