SPFx 1.11 version of smart profile photo editor

This commit is contained in:
Hugo Bernier 2020-08-12 01:16:25 -04:00
parent d78f91462b
commit 58d62e958f
50 changed files with 22134 additions and 0 deletions

View File

@ -0,0 +1,25 @@
# EditorConfig helps developers define and maintain consistent
# coding styles between different editors and IDEs
# editorconfig.org
root = true
[*]
# change these settings to your own preference
indent_style = space
indent_size = 2
# we recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
[{package,bower}.json]
indent_style = space
indent_size = 2

View File

@ -0,0 +1,32 @@
# Logs
logs
*.log
npm-debug.log*
# Dependency directories
node_modules
# Build generated files
dist
lib
solution
temp
*.sppkg
# Coverage directory used by tools like istanbul
coverage
# OSX
.DS_Store
# Visual Studio files
.ntvs_analysis.dat
.vs
bin
obj
# Resx Generated Code
*.resx.ts
# Styles Generated Code
*.scss.ts

View File

@ -0,0 +1,12 @@
{
"@microsoft/generator-sharepoint": {
"isCreatingSolution": true,
"environment": "spo",
"version": "1.11.0",
"libraryName": "smart-profile-photo-editor",
"libraryId": "82fe5f5d-8152-449d-b94d-45ba84066443",
"packageManager": "npm",
"isDomainIsolated": false,
"componentType": "webpart"
}
}

View File

@ -0,0 +1,91 @@
# Smart Profile Photo Editor
## Summary
Uses [Azure Cognitive Services](https://azure.microsoft.com/en-us/services/cognitive-services/) to analyze and approve or reject user-submitted photos.
![picture of the web part in action](./assets/WebPartPreview.gif)
## Used SharePoint Framework Version
![1.11.0](https://img.shields.io/badge/version-1.11.0-green.svg)
## Applies to
* [SharePoint Framework](https:/dev.office.com/sharepoint)
* [Office 365 tenant](https://dev.office.com/sharepoint/docs/spfx/set-up-your-development-environment)
## Prerequisites
This sample requires an [**Azure Cognitive Services**](https://azure.microsoft.com/en-us/services/cognitive-services/) resource instance in order to analyze submitted photographs.
To configure your key and endpoint, use the following steps:
1. If you don't already have an Azure Cognitive Services key, [create a cognitive service resource](https://azure.microsoft.com/en-us/try/cognitive-services/) and select **Get API Key** by the **Computer Vision**.
2. Create a **Computer Vision** resource
3. Make note of the **Key** and **Endpoint**.
4. Edit the web part's properties and update the **Key** and **Endpoint** settings
### Using SharePoint Online Tenant Properties
If you do not wish to reveal your Azure Cognitive Service API key (or prompt users to enter it), you can pre-configure values using [SharePoint Online Tenant Properties](https://docs.microsoft.com/en-us/sharepoint/dev/spfx/tenant-properties?tabs=sprest).
To do so, follow these steps:
1. If you don't already have an Azure Cognitive Services key, [create a cognitive service resource](https://azure.microsoft.com/en-us/try/cognitive-services/) and select **Get API Key** by the **Computer Vision**.
2. Create a **Computer Vision** resource
3. Make note of the **Key** and **Endpoint**.
4. Using [Office365 CLI](https://pnp.github.io/office365-cli?utm_source=msft_docs&utm_medium=page&utm_campaign=Use+SharePoint+Online+tenant+properties), set the storage entity by using the following commands:
```PowerShell
spo storageentity set --appCatalogUrl <appCatalogUrl> --key azurekey --value <value of the key>
spo storageentity set --appCatalogUrl <appCatalogUrl> --key azureendpoint --value <value of the endpoint>
```
5. If you want to verify that your key and endpoint are stored, use the following command to list all your tenant properties:
```PowerShell
spo storageentity list --appCatalogUrl <appCatalogUrl>
```
6. Edit the `ProfilePhotoEditorWebPart.manifest.json` and set the `useStorageEntity` property to `true`. This will cause the web part to hide the Azure Cognitive Services property pane configuration group and use the tenant properties.
## Solution
Solution|Author(s)
--------|---------
react-smart-profile-photo-editor | Hugo Bernier ([Tahoe Ninjas](http://tahoeninjas.blog), @bernierh)
## Version history
Version|Date|Comments
-------|----|--------
1.0|October 15, 2019|Initial release
1.1|August 12, 2020| Upgraded to SPFx 1.11; Added placeholder, markdown control, and property pane controls for API key and endpoint
## Disclaimer
**THIS CODE IS PROVIDED *AS IS* WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING ANY IMPLIED WARRANTIES OF FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABILITY, OR NON-INFRINGEMENT.**
---
## Minimal Path to Awesome
* Clone this repository
* in the command line run:
* `npm install`
* `gulp serve`
## Features
This web part demonstrates the following concepts:
* Uploading images
* Creating a drag and drop target for uploading images
* Using a web cam to capture images
* Retrieving settings from the SharePoint Online tenant properties
* Using Azure Cognitive Services
<img src="https://telemetry.sharepointpnp.com/sp-dev-fx-webparts/samples/react-smart-profile-photo-editor" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

View File

@ -0,0 +1,20 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/config.2.0.schema.json",
"version": "2.0",
"bundles": {
"avatar-editor-web-part": {
"components": [
{
"entrypoint": "./lib/webparts/profilePhotoEditor/ProfilePhotoEditorWebPart.js",
"manifest": "./src/webparts/profilePhotoEditor/ProfilePhotoEditorWebPart.manifest.json"
}
]
}
},
"externals": {},
"localizedResources": {
"ProfilePhotoEditorWebPartStrings": "lib/webparts/profilePhotoEditor/loc/{locale}.js",
"ControlStrings": "node_modules/@pnp/spfx-controls-react/lib/loc/{locale}.js",
"PropertyControlStrings": "node_modules/@pnp/spfx-property-controls/lib/loc/{locale}.js"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/copy-assets.schema.json",
"deployCdnPath": "temp/deploy"
}

View File

@ -0,0 +1,7 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/deploy-azure-storage.schema.json",
"workingDir": "./temp/deploy/",
"account": "<!-- STORAGE ACCOUNT NAME -->",
"container": "smart-profile-photo-editor",
"accessKey": "<!-- ACCESS KEY -->"
}

View File

@ -0,0 +1,19 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/package-solution.schema.json",
"solution": {
"name": "smart-profile-photo-editor",
"id": "82fe5f5d-8152-449d-b94d-45ba84066443",
"version": "1.1.0.0",
"includeClientSideAssets": true,
"isDomainIsolated": false,
"webApiPermissionRequests": [
{
"resource": "Microsoft Graph",
"scope": "User.ReadWrite"
}
]
},
"paths": {
"zippedPackage": "solution/smart-profile-photo-editor.sppkg"
}
}

View File

@ -0,0 +1,10 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/core-build/serve.schema.json",
"port": 4321,
"https": true,
"initialPage": "https://localhost:5432/workbench",
"api": {
"port": 5432,
"entryPath": "node_modules/@microsoft/sp-webpart-workbench/lib/api/"
}
}

View File

@ -0,0 +1,4 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx-build/write-manifests.schema.json",
"cdnBasePath": "<!-- PATH TO CDN -->"
}

View File

@ -0,0 +1,7 @@
'use strict';
const gulp = require('gulp');
const build = require('@microsoft/sp-build-web');
build.addSuppression(`Warning - [sass] The local CSS class 'ms-Grid' is not camelCase and will not be type-safe.`);
build.initialize(gulp);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,55 @@
{
"name": "smart-profile-photo-editor",
"version": "1.1.0",
"private": true,
"main": "lib/index.js",
"engines": {
"node": ">=0.10.0"
},
"scripts": {
"build": "gulp bundle",
"clean": "gulp clean",
"test": "gulp test"
},
"dependencies": {
"@azure/cognitiveservices-computervision": "^6.0.0",
"@azure/ms-rest-js": "^2.0.5",
"@microsoft/sp-core-library": "1.11.0",
"@microsoft/sp-dialog": "1.11.0",
"@microsoft/sp-lodash-subset": "1.11.0",
"@microsoft/sp-office-ui-fabric-core": "1.11.0",
"@microsoft/sp-property-pane": "1.11.0",
"@microsoft/sp-webpart-base": "1.11.0",
"@pnp/common": "^1.3.8",
"@pnp/graph": "^1.3.8",
"@pnp/logging": "^1.3.8",
"@pnp/odata": "^1.3.8",
"@pnp/sp": "^1.3.8",
"@pnp/spfx-controls-react": "^1.19.0",
"@pnp/spfx-property-controls": "^1.20.0-beta.1472053",
"cropperjs": "^1.5.6",
"ms-rest-azure": "^3.0.0",
"office-ui-fabric-react": "6.214.0",
"react": "16.8.5",
"react-butterfiles": "^1.3.3",
"react-cropper": "^1.3.0",
"react-cropperjs": "^1.2.5",
"react-dom": "16.8.5",
"react-webcam": "^3.1.1"
},
"resolutions": {
"@types/react": "16.8.8"
},
"devDependencies": {
"@microsoft/rush-stack-compiler-2.9": "0.7.16",
"@microsoft/rush-stack-compiler-3.3": "0.3.5",
"@microsoft/sp-build-web": "1.11.0",
"@microsoft/sp-module-interfaces": "1.11.0",
"@microsoft/sp-tslint-rules": "1.11.0",
"@microsoft/sp-webpart-workbench": "1.11.0",
"@types/chai": "3.4.34",
"@types/mocha": "2.2.38",
"ajv": "~5.2.2",
"gulp": "~3.9.1"
}
}

View File

@ -0,0 +1 @@
// A file is required to be in the root of the /src directory by the TypeScript compiler

View File

@ -0,0 +1,38 @@
import { IAnalysisService } from "./IAnalysisService";
import { AnalyzeImageInStreamResponse } from '@azure/cognitiveservices-computervision/esm/models';
import { ComputerVisionClient } from '@azure/cognitiveservices-computervision';
import { ApiKeyCredentials } from '@azure/ms-rest-js';
export class AnalysisService implements IAnalysisService {
private readonly key: string = undefined;
private readonly endpoint: string = undefined;
/**
*
*/
constructor(apiKey: string, endpoint: string) {
this.key = apiKey;
this.endpoint = endpoint;
}
public async AnalyzeImage(dataUrl: string): Promise<AnalyzeImageInStreamResponse> {
let base64data: string = dataUrl.replace(/^data:image\/png;base64,|^data:image\/jpeg;base64,|^data:image\/jpg;base64,|^data:image\/jpeg;base64,/, '');
var buf = new Buffer(base64data, 'base64');
let computerVisionClient = new ComputerVisionClient(
new ApiKeyCredentials({ inHeader: { 'Ocp-Apim-Subscription-Key': this.key } }), this.endpoint);
var analysis: AnalyzeImageInStreamResponse = (await computerVisionClient.analyzeImageInStream(buf, {
visualFeatures: ["Categories",
"Adult",
"Tags",
"Description",
"Faces",
"Color",
"ImageType",
"Objects"]
}));
return analysis;
}
}

View File

@ -0,0 +1,5 @@
import { AnalyzeImageInStreamResponse } from '@azure/cognitiveservices-computervision/esm/models';
export interface IAnalysisService {
AnalyzeImage(dataUrl: string): Promise<AnalyzeImageInStreamResponse>;
}

View File

@ -0,0 +1,158 @@
import { IAnalysisService } from ".";
import { AnalyzeImageInStreamResponse } from '@azure/cognitiveservices-computervision/esm/models';
const FAKE_DELAY: number = 5000;
const GOOD_REPONSE: any = {
categories: [
{
name: "people_portrait",
score: 0.953125,
detail: {
celebrities: []
}
}],
adult:
{
isAdultContent: false,
isRacyContent: false,
isGoryContent: false,
adultScore: 0.05552997067570686,
racyScore: 0.05687160789966583,
goreScore: 0.003745682304725051
},
color: {
dominantColorForeground: "Black",
dominantColorBackground: "Black",
dominantColors: ["Black"],
accentColor: "C5064F",
isBWImg: false, "isBwImg": false
},
imageType: {
clipArtType: 0,
lineDrawingType: 0
},
tags: [{ name: "person", confidence: 0.9995626211166382 },
{ name: "man", confidence: 0.9974520206451416 },
{ name: "necktie", confidence: 0.9936947226524353 },
{ name: "human face", confidence: 0.9810442328453064 },
{ name: "suit", confidence: 0.9699360132217407 },
{ name: "portrait", confidence: 0.9696626663208008 },
{ name: "tie", confidence: 0.9663352966308594 },
{ name: "wearing", confidence: 0.927700400352478 },
{ name: "smile", confidence: 0.9273260831832886 },
{ name: "clothing", confidence: 0.9267553091049194 },
{ name: "indoor", confidence: 0.9261177778244019 },
{ name: "headshot", confidence: 0.8910813331604004 },
{ name: "dark", confidence: 0.7800303101539612 },
{ name: "shirt", confidence: 0.7269779443740845 },
{ name: "beard", confidence: 0.7061726450920105 },
{ name: "posing", confidence: 0.6241326928138733 },
{ name: "face", confidence: 0.6114757061004639 },
{ name: "jacket", confidence: 0.5651172995567322 },
{ name: "human beard", confidence: 0.5122537016868591 },
{ name: "dressed", confidence: 0.4890264570713043 }],
description: {
tags: [
"person",
"man",
"necktie",
"suit",
"wearing",
"indoor",
"clothing",
"dark",
"posing",
"jacket",
"looking",
"dressed",
"camera",
"smiling",
"photo",
"glasses",
"shirt",
"standing",
"red",
"black",
"holding",
"young",
"purple",
"pink",
"white"],
captions: [
{
text: "a man wearing a suit and tie smiling at the camera",
confidence: 0.9828590384248658
}]
},
faces: [
{
age: 45,
gender: "Male",
faceRectangle: {
left: 63,
top: 54,
width: 82,
height: 82
}
}],
objects: [
{
rectangle: {
x: 110,
y: 159,
w: 42,
h: 41
},
object: "tie",
confidence: 0.723
},
{
rectangle:
{
x: 2,
y: 127,
w: 196,
h: 73
},
object: "suit",
confidence: 0.545
},
{
rectangle:
{
x: 3,
y: 23,
w: 194,
h: 177
},
object: "person",
confidence: 0.897
}],
requestId: "d6556475-6e77-484e-97b2-7dcb5c02f74c",
metadata: {
width: 200,
height: 200,
format: "Png"
}
};
export class MockAnalysisService implements IAnalysisService {
/**
* Constructor doesn't do anything, it just mimics the real one
*/
constructor(_apiKey: string, _endpoint: string) {
}
public AnalyzeImage(_dataUrl: string): Promise<AnalyzeImageInStreamResponse> {
return new Promise<AnalyzeImageInStreamResponse>((resolve) => {
// pretend we're getting the data from Azure
setTimeout(() => {
resolve(GOOD_REPONSE);
}, FAKE_DELAY);
});
}
}

View File

@ -0,0 +1,3 @@
export * from './IAnalysisService';
export * from './AnalysisService';
export * from './MockAnalysisService';

View File

@ -0,0 +1,3 @@
export interface IStorageEntityService {
GetStorageEntity(storageKey: string): Promise<string>;
}

View File

@ -0,0 +1,24 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { IStorageEntityService } from ".";
const FAKE_DELAY: number = 200;
/**
* Mocks a storage entity provider for testing purposes
*/
export class MockStorageEntityService implements IStorageEntityService {
/**
*
*/
constructor(_context: WebPartContext) {
}
public GetStorageEntity = async (storageKey: string): Promise<string> => {
return new Promise<string>((resolve) => {
// pretend we're getting the data from a service
setTimeout(() => {
resolve(storageKey);
}, FAKE_DELAY);
});
}
}

View File

@ -0,0 +1,30 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { SPHttpClientResponse, SPHttpClient } from "@microsoft/sp-http";
import { IStorageEntityService } from ".";
export class StorageEntityService implements IStorageEntityService {
private context: WebPartContext = undefined;
/**
*
*/
constructor(context: WebPartContext) {
this.context = context;
}
public GetStorageEntity = async (storageKey: string): Promise<string> => {
// Get the page context
const { absoluteUrl } = this.context.pageContext.web;
// Construct the storage key URL
const apiUrl: string = `${absoluteUrl}/_api/web/GetStorageEntity('${storageKey}')`;
// Get the response
const response: SPHttpClientResponse = await this.context.spHttpClient.get(apiUrl, SPHttpClient.configurations.v1);
// Read the value from the JSON
const json: any = await response.json();
// Return the value
return json.Value;
}
}

View File

@ -0,0 +1,3 @@
export * from './StorageEntityService';
export * from './IStorageEntityService';
export * from './MockStorageEntityService';

View File

@ -0,0 +1,31 @@
{
"$schema": "https://developer.microsoft.com/json-schemas/spfx/client-side-web-part-manifest.schema.json",
"id": "8a6108f9-4648-403d-b3bb-75e1a451fffd",
"alias": "ProfilePhotoEditorWebPart",
"componentType": "WebPart",
"version": "*",
"manifestVersion": 2,
"requiresCustomScript": false,
"supportedHosts": ["SharePointWebPart"],
"preconfiguredEntries": [
{
"groupId": "5c03119e-3074-46fd-976b-c60198311f70",
"group": { "default": "Other" },
"title": { "default": "Profile Photo Editor" },
"description": { "default": "Upload your own profile photo" },
"iconImageUrl": "data:image/svg+xml,%3C?xml version='1.0' encoding='iso-8859-1'?%3E%3C!--Generator: Adobe Illustrator 18.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0)--%3E%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 60 60'%3E%3Cpath d='M52.179 40.5l-5.596 8.04-3.949-3.241c-.426-.352-1.057-.287-1.407.138-.351.427-.289 1.058.139 1.407l4.786 3.929c.179.148.404.227.634.227.045 0 .091-.003.137-.01.276-.038.524-.19.684-.419l6.214-8.929c.315-.453.204-1.076-.25-1.392-.454-.317-1.076-.204-1.392.25z'/%3E%3Cpath d='M54.164 35.163c.545-2.185.836-4.421.836-6.663C55 13.337 42.664 1 27.5 1S0 13.337 0 28.5c0 8.01 3.444 15.229 8.927 20.259l-.026.023.891.751c.056.047.117.086.173.133.477.396.972.772 1.476 1.136.159.115.318.23.479.341.535.369 1.085.719 1.646 1.051.122.071.244.141.366.211.613.349 1.239.678 1.881.981.047.022.094.042.141.064 2.089.971 4.319 1.684 6.65 2.105.062.011.123.022.185.033.723.125 1.455.225 2.197.292.09.008.181.013.271.021.741.06 1.487.099 2.243.099 3.262 0 6.454-.577 9.503-1.702C39.389 57.168 42.984 59 47 59c7.168 0 13-5.832 13-13 0-4.522-2.323-8.508-5.836-10.837zM2 28.5C2 14.439 13.439 3 27.5 3S53 14.439 53 28.5c0 1.903-.214 3.804-.639 5.666-.017-.008-.036-.013-.053-.021-.376-.169-.762-.32-1.156-.453-.034-.011-.067-.026-.101-.037-.411-.135-.83-.251-1.258-.345-.02-.005-.04-.011-.06-.016-.417-.09-.841-.158-1.271-.207-.03-.004-.06-.01-.09-.014C47.921 33.027 47.464 33 47 33c-5.923 0-10.923 3.986-12.485 9.413-.438-.413-.697-.988-.697-1.613v-2.957c.198-.243.405-.518.617-.817 1.096-1.547 1.975-3.269 2.616-5.123 1.266-.602 2.085-1.864 2.085-3.289v-3.545c0-.866-.318-1.708-.886-2.369v-4.667c.052-.52.236-3.448-1.883-5.864C34.524 10.065 31.541 9 27.5 9s-7.024 1.065-8.867 3.168c-2.119 2.416-1.935 5.346-1.883 5.864v4.667c-.568.661-.886 1.503-.886 2.369v3.545c0 1.101.494 2.128 1.339 2.821.81 3.173 2.476 5.575 3.092 6.389v2.894c0 .816-.445 1.566-1.162 1.958l-7.907 4.313c-.253.138-.502.298-.752.477C5.276 42.792 2 36.022 2 28.5zm23.605 25.422c-.109-.008-.218-.015-.326-.025-.634-.056-1.266-.131-1.893-.234-.026-.004-.052-.01-.077-.014-1.327-.222-2.632-.548-3.903-.974-.034-.011-.068-.023-.102-.035-1.237-.42-2.44-.939-3.601-1.544-.067-.035-.135-.068-.201-.103-.515-.275-1.019-.573-1.515-.883-.143-.09-.284-.181-.425-.273-.456-.298-.905-.608-1.343-.936-.045-.034-.088-.07-.133-.104.032-.018.064-.036.097-.054l7.907-4.313c1.359-.742 2.204-2.165 2.204-3.714v-3.603l-.233-.278c-.021-.025-2.176-2.634-2.999-6.215l-.091-.396-.341-.221c-.481-.311-.769-.831-.769-1.392v-3.545c0-.465.198-.898.557-1.223l.33-.298v-5.57l-.009-.131c-.003-.024-.298-2.429 1.396-4.36C21.583 11.837 24.06 11 27.5 11c3.425 0 5.897.83 7.346 2.466 1.692 1.911 1.415 4.361 1.413 4.381l-.009 5.701.33.298c.359.324.557.758.557 1.223v3.545c0 .724-.475 1.356-1.181 1.574l-.498.154-.16.496c-.589 1.833-1.429 3.525-2.496 5.032-.259.367-.514.695-.736.948l-.248.283V40.8c0 1.587.868 3.015 2.268 3.746-.053.478-.086.962-.086 1.454 0 .292.01.583.029.873.007.103.021.205.031.307.009.096.018.191.029.287.01.09.015.181.027.27.023.17.056.338.086.507.02.115.035.231.058.345l.003.012c.006.03.015.058.021.088.031.146.068.291.104.436.025.101.045.202.072.302.024.088.055.173.081.26.034.116.07.231.108.345.024.072.042.145.067.216.07.201.15.399.23.596.001.003.002.005.003.008.061.15.125.297.191.444.049.109.1.218.152.326.034.071.064.143.099.213.023.046.05.09.074.136.084.163.173.322.264.48.027.047.051.096.078.143C33.119 53.527 30.33 54 27.5 54c-.634 0-1.266-.031-1.895-.078zM47 57c-3.661 0-6.901-1.805-8.902-4.564-.083-.114-.158-.231-.236-.347-.054-.08-.111-.158-.162-.239-.043-.069-.085-.138-.127-.208-.045-.074-.085-.15-.128-.225-.069-.122-.143-.241-.207-.365-.047-.091-.089-.185-.134-.278-.012-.025-.023-.051-.035-.076-.075-.157-.153-.312-.221-.472-.036-.085-.063-.173-.097-.258-.077-.199-.155-.398-.221-.602-.031-.095-.055-.193-.083-.289-.009-.03-.017-.059-.025-.088-.056-.199-.11-.399-.155-.603-.014-.063-.025-.127-.038-.191-.012-.06-.025-.121-.036-.181-.041-.222-.075-.446-.103-.672-.003-.026-.006-.052-.009-.079-.009-.082-.022-.164-.029-.246C36.021 46.681 36 46.343 36 46c0-.315.021-.626.047-.934.028-.295.067-.599.122-.919l.074-.433C37.298 38.742 41.719 35 47 35c.446 0 .89.034 1.332.089.101.012.199.031.299.046.365.055.728.127 1.086.219.075.019.15.037.225.058.882.247 1.735.601 2.537 1.063C55.773 38.377 58 41.93 58 46c0 6.065-4.935 11-11 11z'/%3E%3C/svg%3E",
"properties": {
"instructions": "To begin, upload or drag a passport-style photo of yourself.",
"requirePortrait": true,
"allowClipart": false,
"allowLinedrawing": false,
"allowRacy": false,
"allowAdult": false,
"allowGory": false,
"useStorageEntity": false
}
}
]
}

View File

@ -0,0 +1,172 @@
import * as React from 'react';
import * as ReactDom from 'react-dom';
import { Version } from '@microsoft/sp-core-library';
import { BaseClientSideWebPart } from "@microsoft/sp-webpart-base";
import { IPropertyPaneConfiguration } from "@microsoft/sp-property-pane";
import {
PropertyPaneTextField,
PropertyPaneToggle,
PropertyPaneLabel
} from '@microsoft/sp-property-pane';
import * as strings from 'ProfilePhotoEditorWebPartStrings';
import ProfilePhotoEditor from './components/ProfilePhotoEditor';
import { IProfilePhotoEditorProps } from './components/IProfilePhotoEditorProps';
// Used for password fields
import { PropertyFieldPassword } from '@pnp/spfx-property-controls/lib/PropertyFieldPassword';
// Used to provide guidance for users
import { PropertyPaneMarkdownContent } from '@pnp/spfx-property-controls/lib/PropertyPaneMarkdownContent';
// Used to retrieve storage key
import { StorageEntityService, IStorageEntityService, MockStorageEntityService } from '../../services/StorageEntityService';
import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
export interface IProfilePhotoEditorWebPartProps {
instructions: string;
requirePortrait: boolean;
allowClipart: boolean;
allowLinedrawing: boolean;
allowRacy: boolean;
allowAdult: boolean;
allowGory: boolean;
forbiddenKeywords: string;
azureKey: string;
azureEndPoint: string;
useStorageEntity: boolean;
}
export default class ProfilePhotoEditorWebPart extends BaseClientSideWebPart<IProfilePhotoEditorWebPartProps> {
// These are used if we're configured to use storage entity service
private _storageAzureKey: string = undefined;
private _storageAzureEndPoint: string = undefined;
/***
* If the web part is configured to use storage entity, will retrieve the
* Azure Endpoint and the Azure Key
*/
protected async onInit() {
if (this.properties.useStorageEntity === undefined) {
this.properties.useStorageEntity = false;
}
if (this.properties.useStorageEntity) {
// Get an instance of the entity storage service
let storageService: IStorageEntityService = undefined;
if (Environment.type === EnvironmentType.Local || Environment.type === EnvironmentType.Test) {
//Running on Unit test environment or local workbench
storageService = new MockStorageEntityService(this.context);
} else if (Environment.type === EnvironmentType.SharePoint) {
//Modern SharePoint page
storageService = new StorageEntityService(this.context);
}
// Attempt to retrieve the Azure key from tenant storage if it isn't specified
this._storageAzureKey = await storageService.GetStorageEntity("azurekey");
this._storageAzureEndPoint = await storageService.GetStorageEntity("azureendpoint");
}
}
/**
* Renders the web part component
*/
public render(): void {
const element: React.ReactElement<IProfilePhotoEditorProps > = React.createElement(
ProfilePhotoEditor,
{
//instructions: this.properties.instructions,
azureVisionKey: this.properties.azureKey,
azureVisionEndpoint: this.properties.azureEndPoint,
context: this.context,
displayMode: this.displayMode,
... this.properties
}
);
ReactDom.render(element, this.domElement);
}
protected onDispose(): void {
ReactDom.unmountComponentAtNode(this.domElement);
}
protected get dataVersion(): Version {
return Version.parse('1.0');
}
protected getPropertyPaneConfiguration(): IPropertyPaneConfiguration {
var config: IPropertyPaneConfiguration = {
pages: [
{
header: {
description: strings.PropertyPaneDescription
},
groups: [
{
groupName: strings.AzureGroupName,
groupFields: [
PropertyPaneMarkdownContent({
markdown: strings.AzureGroupMarkdown,
key: 'azureInstructions'
}),
PropertyFieldPassword("azureKey", {
key: "azureKey",
label: strings.AzureKey,
value: this.properties.azureKey
}),
PropertyPaneTextField('azureEndPoint', {
label: strings.AzureEndPoint
})
]
},
{
groupName: strings.BasicGroupName,
groupFields: [
PropertyPaneLabel('instructions', {
text: strings.BasicInstructions,
}),
PropertyPaneTextField('instructions', {
label: strings.InstructionsFieldLabel,
multiline: true
}),
PropertyPaneToggle('requirePortrait', {
label: strings.RequirePortraitFieldLabel
}),
PropertyPaneToggle('allowClipart', {
label: strings.AllowClipartFieldLabel
}),
PropertyPaneToggle('allowLinedrawing', {
label: strings.AllowLineDrawingFieldLabel
}),
PropertyPaneToggle('allowRacy', {
label: strings.AllowRacyFieldLabel
}),
PropertyPaneToggle('allowAdult', {
label: strings.AllowAdultImagesFieldLabel
}),
PropertyPaneToggle('allowGory', {
label: strings.AllowGoryFieldLabel
}),
PropertyPaneTextField('forbiddenKeywords', {
label: strings.ForbiddenTagsFieldLabel,
multiline: true,
description: strings.ForbiddenTagsFieldDescription
})
]
}
]
}
]
};
// Hide the Azure cognitive services settings if the web part is configured to use storage entity
if (this.properties.useStorageEntity) {
// Remove the first group from the property pane
config.pages[0].groups.splice(0,1);
}
return config;
}
}

View File

@ -0,0 +1,7 @@
.iconGood {
color: green;
}
.iconBad {
color: red;
}

View File

@ -0,0 +1,12 @@
import * as React from 'react';
import styles from './AnalysisChecklist.module.scss';
import { IAnalysisChecklistProps } from '.';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
export default class AnalysisChecklist extends React.Component<IAnalysisChecklistProps, {}> {
public render(): React.ReactElement<IAnalysisChecklistProps> {
return (
<li><Icon iconName={this.props.isValid ? 'StatusCircleCheckmark': 'StatusCircleErrorX'} className={this.props.isValid ?styles.iconGood: styles.iconBad} /> <strong>{this.props.title}:</strong> {this.props.value}</li>
);
}
}

View File

@ -0,0 +1,6 @@
export interface IAnalysisChecklistProps {
isValid: boolean;
title: string;
value: string;
}

View File

@ -0,0 +1,2 @@
export * from './AnalysisChecklist';
export * from './AnalysisChecklist.types';

View File

@ -0,0 +1,64 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { BaseDialog, IDialogConfiguration } from '@microsoft/sp-dialog';
import { IPhotoRequirements } from './IPhotoRequirements';
import { AnalysisDialogContent } from './AnalysisDialogContent';
import { WebPartContext } from '@microsoft/sp-webpart-base';
/**
* Analysis Panel Dialog
*/
export class AnalysisPanelDialog extends BaseDialog {
private readonly imageUrl: string = undefined;
private readonly azureKey: string = undefined;
private readonly azureEndpoint: string = undefined;
private readonly photoRequirements: IPhotoRequirements = undefined;
private readonly context: WebPartContext = undefined;
private readonly blob: Blob = undefined;
constructor(imageUrl: string, azureKey: string, azureEndpoint: string, photoRequirements: IPhotoRequirements, context: WebPartContext, blob: Blob) {
super();
this.imageUrl = imageUrl;
this.azureEndpoint = azureEndpoint;
this.azureKey = azureKey;
this.photoRequirements = photoRequirements;
this.context = context;
this.blob = blob;
}
/**
* Configures a non-blocking dialog
*/
public getConfig(): IDialogConfiguration {
return {
isBlocking: false
};
}
/**
* Renders the icon finder panel
*/
public render(): void {
ReactDOM.render(<AnalysisDialogContent
domElement={document.activeElement.parentElement}
onDismiss={this.onDismiss.bind(this)}
imageUrl={this.imageUrl}
azureEndpoint={this.azureEndpoint}
azureKey={this.azureKey}
photoRequirements={this.photoRequirements}
context={this.context}
blob={this.blob}
/>, this.domElement);
}
/**
* Closes the dialog when dismissed
*/
private onDismiss() {
this.close();
ReactDOM.unmountComponentAtNode(this.domElement);
}
}

View File

@ -0,0 +1,108 @@
@import "~office-ui-fabric-react/dist/sass/References.scss";
// We have to hide this style to prevent this ugly white square from showing
:global {
.ms-Dialog-main {
background-color: transparent;
box-shadow: none;
}
}
.thumbnailImg {
width: 260px;
height: 260px;
}
.laser {
width: 150%;
margin-left: -25%;
background-color: rgb(0, 255, 0);
height: 3px;
position: absolute;
top: 145px;
z-index: 2;
box-shadow: 0 0 4px rgb(217, 255, 47);
animation: scanning 4s infinite;
}
.diode {
animation: beam 0.01s infinite;
}
.panelHeader {
font-size: 21px;
font-weight: 100;
color: $ms-color-neutralDark;
padding: 32px 0 14px;
}
.analysisOutcome {
background-color: $ms-color-neutralLighterAlt;
padding-left:12px;
padding-right:12px;
padding-top:0px;
padding-bottom:12px;
}
.analysisDialog {
//margin-top:-50px;
color: inherit;
}
.iconContainer {
margin-bottom: 16px;
font-size: 21px;
text-align: center;
margin-top: 16px;
}
.icon
{
line-height: 44px;
border-radius: 50%;
width:44px;
height:44px;
color: #ffffff;
animation: popup .45s;
}
.iconGood {
background-color: #599b00;
}
.iconBad {
background-color: red;
}
.analysisChecklist {
list-style-type: none;
padding-inline-start: 0px;
margin-top: 0px;
}
.section {
padding-top: 8px;
}
@keyframes beam {
50% {
opacity: 0;
}
}
@keyframes scanning {
50% {
transform: translateY(260px);
}
}
@keyframes popup {
from {
transform: scale(0.0);
}
to {
transform: scale(1.25);
}
to {
transform: scale(1.0);
}
}

View File

@ -0,0 +1,284 @@
import * as React from 'react';
import { IAnalysisDialogContentProps } from './IAnalysisDialogContentProps';
import { IAnalysisDialogContentState } from './IAnalysisDialogContentState';
import { Panel } from 'office-ui-fabric-react/lib/Panel';
import { Label } from 'office-ui-fabric-react/lib/Label';
import { Shimmer } from 'office-ui-fabric-react/lib/Shimmer';
import { ProgressIndicator } from 'office-ui-fabric-react/lib/ProgressIndicator';
import styles from './AnalysisDialogContent.module.scss';
// Used for localized text
import * as strings from 'ProfilePhotoEditorWebPartStrings';
// Used to determine if we should be making real calls to APIs or just mock calls
import { Environment, EnvironmentType } from '@microsoft/sp-core-library';
import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button';
import { Icon } from 'office-ui-fabric-react/lib/Icon';
import { css } from "@uifabric/utilities/lib/css";
import {
MessageBar,
MessageBarType
} from 'office-ui-fabric-react';
import { Image, ImageFit } from 'office-ui-fabric-react/lib/Image';
import { IAnalysisService, AnalysisService, MockAnalysisService } from '../../../../services/AnalysisServices';
import { AnalyzeImageInStreamResponse, ImageTag } from '@azure/cognitiveservices-computervision/esm/models';
import AnalysisChecklist from '../AnalysisChecklist/AnalysisChecklist';
import { sp } from "@pnp/sp";
import { MSGraphClient, SPHttpClient } from '@microsoft/sp-http';
export class AnalysisDialogContent extends
React.Component<IAnalysisDialogContentProps, IAnalysisDialogContentState> {
/**
*
*/
constructor(props: IAnalysisDialogContentProps) {
super(props);
this.state = {
isAnalyzing: true,
analysis: undefined,
isValid: false,
isSubmitted: false
};
}
/**
* When the dialog is loaded, perform the analysis
*/
public async componentDidMount() {
if (this.state.isAnalyzing) {
const { azureKey, azureEndpoint, photoRequirements } = this.props;
// Get the analysis service
let service: IAnalysisService = undefined;
if (Environment.type === EnvironmentType.Local || Environment.type === EnvironmentType.Test) {
//Running on Unit test environment or local workbench
service = new MockAnalysisService(azureKey, azureEndpoint);
} else if (Environment.type === EnvironmentType.SharePoint) {
//Modern SharePoint page
service = new AnalysisService(azureKey, azureEndpoint);
}
// Perform the analysis
const analysis: AnalyzeImageInStreamResponse = await service.AnalyzeImage(this.props.imageUrl);
// Evaluate analysis against requirements
const isPortrait: boolean = analysis && analysis.categories && analysis.categories.filter(c => c.name === "people_portrait").length > 0;
const isPortraitValid: boolean = photoRequirements.requirePortrait ? isPortrait : true;
const onlyOnePersonValid: boolean = analysis.faces.length === 1;
const isClipartValid: boolean = photoRequirements.allowClipart ? true : analysis.imageType.clipArtType === 0;
const isLinedrawingValid: boolean = photoRequirements.allowLinedrawing ? true : analysis.imageType.lineDrawingType === 0;
const isAdultValid: boolean = photoRequirements.allowAdult ? true : !analysis.adult.isAdultContent;
const isRacyValid: boolean = photoRequirements.allowRacy ? true : !analysis.adult.isRacyContent;
const isGoryValid: boolean = photoRequirements.allowGory ? true : !analysis.adult.isGoryContent;
// Verify against all forbidden keywords
let invalidKeywords: string[] = [];
if (photoRequirements.forbiddenKeywords && photoRequirements.forbiddenKeywords.length > 0) {
photoRequirements.forbiddenKeywords.forEach((keyword: string) => {
if (analysis.tags.filter((tag: ImageTag) => {
return keyword.toLowerCase() === tag.name;
}).length > 0) {
invalidKeywords.push(keyword);
}
});
}
const keywordsValid: boolean = invalidKeywords.length < 1;
console.log("Invalid keywords", invalidKeywords);
// Photo is valid if it meets all requirements
const isValid: boolean = isPortraitValid
&& onlyOnePersonValid
&& isClipartValid
&& isLinedrawingValid
&& isAdultValid
&& isRacyValid
&& isGoryValid
&& keywordsValid;
this.setState({
isAnalyzing: false,
analysis,
isValid,
isPortrait,
isPortraitValid,
onlyOnePersonValid,
isClipartValid,
isLinedrawingValid,
isAdultValid,
isRacyValid,
isGoryValid,
keywordsValid,
invalidKeywords
});
}
}
public render(): JSX.Element {
const { analysis,
isAnalyzing,
isValid,
isPortrait,
isPortraitValid,
isAdultValid,
isRacyValid,
isGoryValid,
isClipartValid,
isLinedrawingValid,
onlyOnePersonValid,
invalidKeywords,
keywordsValid } = this.state;
if (analysis !== undefined) {
console.log("Analysis", analysis);
}
return (
<Panel
className={styles.analysisDialog}
isOpen={true}
onDismiss={(ev?: React.SyntheticEvent<HTMLElement, Event>) => this.onDismiss(ev)}
isLightDismiss={true}
onRenderFooterContent={this.onRenderFooterContent}
>
<h1 className={styles.panelHeader}>{strings.PanelTitle}</h1>
<Image
className={styles.thumbnailImg}
imageFit={ImageFit.centerContain}
src={this.props.imageUrl}
/>
{isAnalyzing &&
<div className={styles.diode}>
<div className={styles.laser}></div>
</div>
}
{isAnalyzing &&
<ProgressIndicator label={strings.AnalyzingLabel} />
}
<div className={styles.analysisOutcome}>
<div className={styles.section}>
<Label><strong>{strings.DescriptionLabel}</strong></Label>
{isAnalyzing ? <Shimmer /> : analysis.description && analysis.description.captions && analysis.description.captions.length > 0 && <span>{analysis.description.captions[0].text}</span>}
</div>
<div className={styles.section}><Label><strong>{strings.EstimatedAgeLabel}</strong></Label>
{isAnalyzing ? <Shimmer width="10%" /> : analysis.faces.length > 0 && <span>{analysis.faces[0].age}</span>}
</div>
<div className={styles.section}><Label><strong>{strings.GenderLabel}</strong></Label>
{isAnalyzing ? <Shimmer width="20%" /> : analysis.faces.length > 0 && <span>{analysis.faces[0].gender}</span>}
</div>
</div>
{!isAnalyzing &&
<div className={styles.iconContainer} ><Icon iconName={isValid ? "CheckMark" : "StatusCircleErrorX"} className={css(styles.icon, isValid ? styles.iconGood : styles.iconBad)} /></div>
}
{!isAnalyzing && isValid &&
<div>{strings.AnalysisGoodLabel}</div>
}
{!isAnalyzing && !isValid &&
<div>{strings.AnalysisBadLabel}</div>
}
{!isAnalyzing &&
<div className={styles.section}>
<ul className={styles.analysisChecklist}>
<AnalysisChecklist title={strings.PortraitLabel} value={isPortrait ? strings.YesLabel : strings.NoLabel} isValid={isPortraitValid} />
<AnalysisChecklist title={strings.NumberOfFacesDetectedLabel} value={`${analysis.faces.length}`} isValid={onlyOnePersonValid} />
<AnalysisChecklist title={strings.ClipartLabel} value={analysis.imageType.clipArtType > 0 ? strings.YesLabel : strings.NoLabel} isValid={isClipartValid} />
<AnalysisChecklist title={strings.LineDrawingLabel} value={analysis.imageType.lineDrawingType > 0 ? strings.YesLabel : strings.NoLabel} isValid={isLinedrawingValid} />
<AnalysisChecklist title={strings.RacyLabel} value={analysis.adult.isRacyContent ? strings.YesLabel : strings.NoLabel} isValid={isRacyValid} />
<AnalysisChecklist title={strings.AdultLabel} value={analysis.adult.isAdultContent ? strings.YesLabel : strings.NoLabel} isValid={isAdultValid} />
<AnalysisChecklist title={strings.GoryLabel} value={analysis.adult.isGoryContent ? strings.YesLabel : strings.NoLabel} isValid={isGoryValid} />
<AnalysisChecklist title={strings.ForbiddenKeywordsLabel} value={keywordsValid ? strings.NoKeywords : invalidKeywords.join(', ')} isValid={keywordsValid} />
</ul>
</div>
}
{!isAnalyzing && isValid &&
<div>{strings.PanelInstructionsLabel}<strong>{strings.UpdateButtonLabel}</strong>.</div>
}
{this.state.isSubmitted &&
<MessageBar
messageBarType={MessageBarType.success}
isMultiline={false}
>
{strings.SuccessMessage}
</MessageBar>
}
</Panel>
);
}
private onRenderFooterContent = (): JSX.Element => {
return (
<div>
<PrimaryButton onClick={(_ev) => this.onUpdateProfilePhoto()} style={{ marginRight: '8px' }} disabled={this.state.isValid !== true}>
{strings.UpdateButtonLabel}
</PrimaryButton>
<DefaultButton onClick={(_ev) => this.onDismiss()}>{strings.CancelButtonLabel}</DefaultButton>
</div>
);
}
private onUpdateProfilePhoto = async (_ev?: React.SyntheticEvent<HTMLElement, Event>) => {
console.log("Submitting photo");
const profileBlob: Blob = this.props.blob;
// Get image array buffer
this.updateProfilePic(profileBlob);
}
private async updateProfilePic(buffer) {
console.log("Update profile pic", buffer);
this.props.context.msGraphClientFactory
.getClient().then((client: MSGraphClient) => {
client
.api("me/photo/$value")
.version("v1.0").header("Content-Type", buffer.type).put(buffer, (error, res) => {
if (error) {
console.log("Error updating profile", error);
} else {
console.log("Profile property Updated");
this.setState({
isSubmitted: true
});
}
});
});
}
private onDismiss = (_ev?: React.SyntheticEvent<HTMLElement, Event>) => {
this.props.onDismiss();
}
// private dataURLtoBlob = (dataurl: string): Blob => {
// var arr = dataurl.split(','), mime = arr[0].match(/:(.*?);/)[1],
// bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
// while (n--) {
// u8arr[n] = bstr.charCodeAt(n);
// }
// return new Blob([u8arr], { type: mime });
// }
}

View File

@ -0,0 +1,30 @@
import { AnalyzeImageInStreamResponse } from '@azure/cognitiveservices-computervision/esm/models';
import { WebPartContext } from '@microsoft/sp-webpart-base';
import { IPhotoRequirements } from './IPhotoRequirements';
export interface IAnalysisDialogContentProps {
imageUrl: string;
azureKey: string;
azureEndpoint: string;
photoRequirements: IPhotoRequirements;
/**
* The web part context we'll need to call APIs
*/
context: WebPartContext;
blob: Blob;
/**
* The DOM element to attach the dialog to
*/
domElement: any;
/**
* Dismiss handler
*/
onDismiss: () => void;
}

View File

@ -0,0 +1,19 @@
import { AnalyzeImageInStreamResponse } from '@azure/cognitiveservices-computervision/esm/models';
export interface IAnalysisDialogContentState {
// This space for rent
isAnalyzing: boolean;
analysis?: AnalyzeImageInStreamResponse;
isValid?: boolean;
isPortrait?: boolean;
isPortraitValid?: boolean;
onlyOnePersonValid?: boolean;
isClipartValid?: boolean;
isLinedrawingValid?: boolean;
isAdultValid?: boolean;
isRacyValid?: boolean;
isGoryValid?: boolean;
keywordsValid?: boolean;
invalidKeywords?: string[];
isSubmitted: boolean;
}

View File

@ -0,0 +1,9 @@
export interface IPhotoRequirements {
requirePortrait: boolean;
allowClipart: boolean;
allowLinedrawing: boolean;
allowRacy: boolean;
allowAdult: boolean;
allowGory: boolean;
forbiddenKeywords: string[];
}

View File

@ -0,0 +1,2 @@
export * from './AnalysisDialog';
export * from './IPhotoRequirements';

View File

@ -0,0 +1,19 @@
import { WebPartContext } from "@microsoft/sp-webpart-base";
import { DisplayMode } from '@microsoft/sp-core-library';
export interface IProfilePhotoEditorProps {
instructions: string;
context: WebPartContext;
requirePortrait: boolean;
allowClipart: boolean;
allowLinedrawing: boolean;
allowRacy: boolean;
allowAdult: boolean;
allowGory: boolean;
forbiddenKeywords: string;
azureVisionEndpoint: string;
azureVisionKey: string;
displayMode: DisplayMode;
}

View File

@ -0,0 +1,5 @@
export interface IProfilePhotoEditorState {
errors: Array<any>;
showWebCamDialog: boolean;
imageUrl?: string;
}

View File

@ -0,0 +1,65 @@
@import '~office-ui-fabric-react/dist/sass/References.scss';
.profilePhotoEditor {
.dropZone {
cursor: pointer;
width: 100%;
min-height: 400px;
border-width: 2px;
border-color: $ms-color-neutralLight;
border-style: dashed;
}
:global {
.ms-Button {
border:none;
}
.ms-CommandBar {
margin-right: -4px;
}
.cropper-container {
width: 100%!important;
}
}
.hiddenDropZone {
width: 0px;
height:0px;
}
.cropper {
height: 400px;
width: 100%;
}
.placeholderDescription {
width: 65%;
vertical-align: middle;
margin: 0 auto;
text-align: center;
min-height: 400px;
.placeholderDescriptionText {
color: $ms-color-neutralSecondary;
font-size: 17px;
display: block;
font-weight: 100;
position: absolute;
top: 50%;
transform: translateY(-50%);
}
}
:global {
.cropper-point, .cropper-point.point-se {
height:12px;
width: 12px;
background-color: white;
opacity: 0.75;
}
}
}

View File

@ -0,0 +1,316 @@
import * as React from 'react';
import styles from './ProfilePhotoEditor.module.scss';
import { IProfilePhotoEditorProps } from './IProfilePhotoEditorProps';
import { IProfilePhotoEditorState } from './IProfilePhotoEditorState';
// Used for localized text
import * as strings from 'ProfilePhotoEditorWebPartStrings';
// Used to allow dragging and dropping files
import Files from "react-butterfiles";
// Used to crop image
import Cropper from 'react-cropper';
import 'cropperjs/dist/cropper.css';
// Used for messages
import { MessageBar, MessageBarType } from 'office-ui-fabric-react';
// Used for toolbar
import { CommandBar } from 'office-ui-fabric-react/lib/CommandBar';
import { AnalysisPanelDialog, IPhotoRequirements } from './AnalysisDialog';
// Used to determine if we should be making real calls to APIs or just mock calls
import { Environment, EnvironmentType, DisplayMode } from '@microsoft/sp-core-library';
import { WebCamDialog } from './WebCamDialog';
// Used to prompt user for configuration
import { Placeholder } from "@pnp/spfx-controls-react/lib/Placeholder";
// Constants used for file upload settings
const maxSize: string = '4mb';
const acceptedFiles: string[] = ["image/jpg", "image/jpeg", "image/png"];
/**
* Displays an upload area with support for webcam and allows users to crop an image
*
* Used in this class:
* - Cropper: provides image cropping functionality
* - Files: provides drag and drop file upload capability
*/
export default class ProfilePhotoEditor extends React.Component<IProfilePhotoEditorProps, IProfilePhotoEditorState> {
/**
* Holds a reference to the cropper
*/
private cropper: Cropper = undefined;
/**
* Holds a reference to the div that can be clicked to launch the file browser
*/
private fileBrowser: HTMLDivElement = undefined;
/**
* Constructor
*/
constructor(props: IProfilePhotoEditorProps) {
super(props);
this.state = {
errors: [],
showWebCamDialog: false
};
}
public render(): React.ReactElement<IProfilePhotoEditorProps> {
const { azureVisionEndpoint, azureVisionKey } = this.props;
const needsConfiguration = azureVisionEndpoint === undefined || azureVisionKey === undefined;
return (
<div className={styles.profilePhotoEditor}>
{ needsConfiguration ? this.renderPlaceholder() : this.renderPhotoEditor() }
</div>
);
}
private renderPlaceholder(): JSX.Element {
return <Placeholder iconName='AzureLogo'
iconText='Configure API key'
description={`Before you can use this web part, you'll need to provide an API key for Azure Cognitive Services.`}
buttonLabel='Configure'
hideButton={this.props.displayMode === DisplayMode.Read}
onConfigure={this._onConfigure} />;
}
private renderPhotoEditor(): JSX.Element {
return <>
{this.state.errors.length > 0 && (
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={false}
onDismiss={(_ev) => this.resetFiles()}
dismissButtonAriaLabel={strings.CloseLabel}
truncated={false}
>
{this.state.errors.map((error: any, _index: number) => {
console.log("ERROR:", error);
let errorMessage: string = strings.UnexpectedErrorLabel;
switch (error.type) {
case "unsupportedFileType":
errorMessage = strings.UnsupportedFileTypeErrorLabel;
break;
case "maxSizeExceeded":
errorMessage = strings.MaxSizeExceededErrorLabel;
break;
case "multipleNotAllowed":
errorMessage = strings.MultipleFileTypeErrorLabel;
break;
}
return <p><b>{strings.ErrorLabel}:</b> {errorMessage}</p>;
})}
</MessageBar>
)}
<CommandBar
items={this.getCommandBarItems()}
overflowItems={this.getOverflowItems()}
overflowButtonProps={{ ariaLabel: strings.MoreCommandAriaLabel }}
farItems={this.getFarItems()}
ariaLabel={strings.CommandBarAriaLabel}
/>
<Files
accept={acceptedFiles}
convertToBase64
maxSize={maxSize}
onSuccess={this.handleSuccess}
onError={this.handleErrors}
>
{({ browseFiles, getDropZoneProps }) => (
<>
{this.state.imageUrl !== undefined ? (
<div
{...getDropZoneProps({
className: styles.dropZone
})}>
<Cropper
className={styles.cropper}
aspectRatio={1}
guides={true}
src={this.state.imageUrl}
ref={cropper => { this.cropper = cropper; }}
/>
<div ref={(elm) => this.fileBrowser = elm}
onClick={browseFiles}
{...getDropZoneProps({
className: styles.hiddenDropZone
})}
/>
</div>
) : (
<div
ref={(elm) => this.fileBrowser = elm}
onClick={browseFiles}
{...getDropZoneProps({
className: styles.dropZone
})}
>
<div className={styles.placeholderDescription}>
<span className={styles.placeholderDescriptionText}>{this.props.instructions}</span>
</div>
</div>
)}
</>
)}
</Files>
{this.state.showWebCamDialog &&
<WebCamDialog
onDismiss={() => {
this.setState({
showWebCamDialog: false
});
}}
onCapture={(imageUrl: string) => {
this.setState({
imageUrl,
showWebCamDialog: false
});
}}
/>}
</>;
}
/**
* Gets called when a file has been successfully uploaded
*/
private handleSuccess = (files: any) => {
this.setState({
imageUrl: files[0].src.base64,
errors: []
});
}
/**
* Gets called when an error has occurred uploading a file
*/
private handleErrors = (errors: any) => {
console.log("Handle errors", errors);
this.setState({
imageUrl: undefined,
errors
});
}
/**
* Resets the editor by removing all files and errors
*/
private resetFiles = () => {
this.setState({
imageUrl: undefined,
errors: []
});
}
/**
* Renders the command bar items
*/
private getCommandBarItems = () => {
return [
{
key: 'upload',
name: strings.UploadButtonName,
iconProps: {
iconName: 'Add'
},
onClick: () => {
this.fileBrowser.click();
}
},
{
key: 'webcam',
name: strings.CameraButtonName,
iconProps: {
iconName: 'Camera'
},
//disabled: true,
title: strings.CameraButtonLabel,
onClick: () => this.getWebCamPhoto()
},
{
key: 'Save',
name: strings.SubmitButtonName,
iconProps: {
iconName: 'Save'
},
disabled: this.state.imageUrl === undefined,
title: this.state.imageUrl === undefined ? strings.SubmitPhotoDisabledTitle : strings.SubmitPhotoTitle,
onClick: () => this.submitPhoto()
}
];
}
/**
* Renders the overflow items -- we don't have any right now
*/
private getOverflowItems = () => {
return [
];
}
/**
* Renders the menu items at the far right of the toolbar
*/
private getFarItems = () => {
return [
{
key: 'reset',
ariaLabel: strings.ResetAriaLabel,
iconProps: {
iconName: 'Refresh'
},
onClick: () => this.resetFiles()
}
];
}
private getWebCamPhoto = () => {
this.setState({
showWebCamDialog: true
});
}
/**
* Calls the dialog to submit the photo
*/
private submitPhoto = () => {
// Get the image to approve
const imageToApprove: string = this.cropper.getCroppedCanvas().toDataURL();
this.cropper.getCroppedCanvas().toBlob((blob: Blob)=> {
console.log("Blob", blob);
const photoRequirements: IPhotoRequirements = {
allowAdult: this.props.allowAdult,
allowClipart: this.props.allowClipart,
allowGory: this.props.allowGory,
allowLinedrawing: this.props.allowLinedrawing,
requirePortrait: this.props.requirePortrait,
allowRacy: this.props.allowRacy,
forbiddenKeywords: this.props.forbiddenKeywords && this.props.forbiddenKeywords.replace('; ', ';').replace(' ;', ';').split(';')
};
// Create a new instance of the analysis dialog
const callout: AnalysisPanelDialog = new AnalysisPanelDialog(imageToApprove, this.props.azureVisionKey, this.props.azureVisionEndpoint, photoRequirements, this.props.context, blob);
// Show the dialog
callout.show();
});
}
private _onConfigure = () => {
// Context of the web part
this.props.context.propertyPane.open();
}
}

View File

@ -0,0 +1,7 @@
.webcamdialog {
:global(.ms-Dialog-main) {
background-color: white;
box-shadow: rgba(0,0,0,0.4) 0 0 5px 0;
}
}

View File

@ -0,0 +1,66 @@
import * as React from 'react';
import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog';
import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button';
import * as strings from 'ProfilePhotoEditorWebPartStrings';
import { IWebCamDialogProps, IWebCamDialogState } from './WebCamDialog.types';
import styles from './WebCamDialog.module.scss';
import Webcam from "react-webcam";
const videoConstraints = {
width: 300,
height: 300,
facingMode: "user"
};
export class WebCamDialog extends React.Component<IWebCamDialogProps, IWebCamDialogState> {
private webcamRef: Webcam = undefined;
public render(): React.ReactElement<IWebCamDialogProps> {
return (
<Dialog
className={styles.webcamdialog}
hidden={false}
onDismiss={this.onDismiss}
dialogContentProps={{
type: DialogType.normal,
title: strings.WebCamDialogTitle,
}}
maxWidth={'356px'}
modalProps={{
isBlocking: false
}}
>
<Webcam
audio={false}
ref={(elm) => this.webcamRef = elm}
screenshotFormat="image/jpeg"
videoConstraints={videoConstraints}
imageSmoothing={true}
onUserMedia={() => console.log("OnUserMedia")}
onUserMediaError={() => console.log("OnUserMediaError")}
screenshotQuality={0.92}
/>
<DialogFooter>
<PrimaryButton onClick={() => this.onCapture()} text={strings.CaptureButtonLabel} />
<DefaultButton onClick={() => this.onDismiss()} text={strings.CancelButtonLabel} />
</DialogFooter>
</Dialog>
);
}
private onCapture = () => {
const imageSrc = this.webcamRef.getScreenshot();
console.log("ImageSrc", imageSrc);
this.props.onCapture(imageSrc);
}
private onDismiss = (_ev?: React.SyntheticEvent<HTMLElement, Event>) => {
this.props.onDismiss();
}
}

View File

@ -0,0 +1,15 @@
export interface IWebCamDialogProps {
/**
* The DOM element to attach the dialog to
*/
//domElement: any;
//hidden: boolean;
/**
* Dismiss handler
*/
onDismiss: () => void;
onCapture: (string) => void;
}
export interface IWebCamDialogState {}

View File

@ -0,0 +1,2 @@
export * from './WebCamDialog';
export * from './WebCamDialog.types';

View File

@ -0,0 +1,63 @@
define([], function() {
return {
CaptureButtonLabel: "Capture",
WebCamDialogTitle: "Insert photo from camera",
NoKeywords: "(none)",
ForbiddenKeywordsLabel: "Forbidden keywords",
ForbiddenTagsFieldDescription: "Enter a semi-colon separated list of tags that should not be allowed.",
ForbiddenTagsFieldLabel: "Forbidden tags",
AllowGoryFieldLabel: "Allow gory images",
AllowAdultImagesFieldLabel: "Allow adult images",
AllowRacyFieldLabel: "Allow racy images",
AllowLineDrawingFieldLabel: "Allow line drawing images",
AllowClipartFieldLabel: "Allow clipart images",
RequirePortraitFieldLabel: "Require portrait photos",
AnalysisBadLabel: "Your photo does not meet one or more criteria. Try again with a photo that meets the following criteria:",
NoLabel: "No",
YesLabel: "Yes",
GoryLabel: "Gory",
AdultLabel: "Adult",
RacyLabel: "Racy",
LineDrawingLabel: "Line drawing",
ClipartLabel: "Clip art",
NumberOfFacesDetectedLabel: "Number of faces detected",
PortraitLabel: "Portrait",
PanelInstructionsLabel: "To update your profile photo, use ",
AnalysisGoodLabel: "Your new photo meets our requirements!",
GenderLabel: "Gender",
EstimatedAgeLabel: "Estimated age",
PanelTitle: "Update your profile photo",
AnalyzingLabel: "Analyzing your photo...",
DescriptionLabel: "Description",
CancelButtonLabel: "Cancel",
UpdateButtonLabel: "Update",
SubmitButtonName: "Submit",
UploadButtonName: "Upload",
CameraButtonName: "Use camera",
ResetAriaLabel: "Reset",
CameraButtonLabel: "Camera function is disabled for this demo",
SubmitPhotoDisabledTitle: "Upload a photo before you can submit",
SubmitPhotoTitle: "Submit your photo",
MultipleFileTypeErrorLabel: "You can only drop one file at a time",
UnsupportedFileTypeErrorLabel: "You can only upload photos (e.g.: .PNG, .JPG, .JPEG)",
UnexpectedErrorLabel: "Unexpected error",
CommandBarAriaLabel: "Use left and right arrow keys to navigate between commands",
MoreCommandAriaLabel: "More commands",
MaxSizeExceededErrorLabel: "Maximum file size exceeded",
ErrorLabel: "Error",
CloseLabel: "Close",
PropertyPaneDescription: "Use this web part to allow users to upload profile photos that meet your photo requirements.",
BasicGroupName: "Photo requirements",
InstructionsFieldLabel: "Instructions",
AzureGroupName: "Azure Cognitive Service",
AzureGroupMarkdown: `
To get started, create an [Azure Cognitive Services](https://azure.microsoft.com/en-us/try/cognitive-services/) resource in your [Azure Portal](https://portal.azure.com).
You'll find your key and endpoint under **Keys and Endpoint**
`,
AzureKey: "Key",
AzureEndPoint: "Endpoint",
BasicInstructions: "Specify your requirements to approve a profile photo.",
SuccessMessage: "Your profile was succesfully updated!"
}
});

View File

@ -0,0 +1,62 @@
declare interface IProfilePhotoEditorWebPartStrings {
CaptureButtonLabel: string;
WebCamDialogTitle: string;
NoKeywords: string;
ForbiddenKeywordsLabel: string;
ForbiddenTagsFieldDescription: string;
ForbiddenTagsFieldLabel: string;
AllowGoryFieldLabel: string;
AllowAdultImagesFieldLabel: string;
AllowRacyFieldLabel: string;
AllowLineDrawingFieldLabel: string;
AllowClipartFieldLabel: string;
RequirePortraitFieldLabel: string;
AnalysisBadLabel: string;
NoLabel: string;
YesLabel: string;
GoryLabel: string;
AdultLabel: string;
RacyLabel: string;
LineDrawingLabel: string;
ClipartLabel: string;
NumberOfFacesDetectedLabel: string;
PortraitLabel: string;
PanelInstructionsLabel: string;
AnalysisGoodLabel: string;
GenderLabel: string;
EstimatedAgeLabel: string;
PanelTitle: string;
AnalyzingLabel: string;
DescriptionLabel: string;
CancelButtonLabel: string;
UpdateButtonLabel: string;
SubmitButtonName: string;
UploadButtonName: string;
CameraButtonName: string;
ResetAriaLabel: string;
CameraButtonLabel: string;
SubmitPhotoDisabledTitle: string;
SubmitPhotoTitle: string;
MultipleFileTypeErrorLabel: string;
UnsupportedFileTypeErrorLabel: string;
UnexpectedErrorLabel: string;
CommandBarAriaLabel: string;
MoreCommandAriaLabel: string;
MaxSizeExceededErrorLabel: string;
ErrorLabel: string;
CloseLabel: string;
PropertyPaneDescription: string;
BasicGroupName: string;
InstructionsFieldLabel: string;
AzureGroupName: string;
AzureGroupMarkdown: string;
AzureKey: string;
AzureEndPoint: string;
BasicInstructions: string;
SuccessMessage: string;
}
declare module 'ProfilePhotoEditorWebPartStrings' {
const strings: IProfilePhotoEditorWebPartStrings;
export = strings;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,39 @@
{
"extends": "./node_modules/@microsoft/rush-stack-compiler-3.3/includes/tsconfig-web.json",
"compilerOptions": {
"target": "es5",
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"jsx": "react",
"declaration": true,
"sourceMap": true,
"experimentalDecorators": true,
"skipLibCheck": true,
"outDir": "lib",
"inlineSources": false,
"strictNullChecks": false,
"noUnusedLocals": false,
"typeRoots": [
"./node_modules/@types",
"./node_modules/@microsoft"
],
"types": [
"es6-promise",
"webpack-env"
],
"lib": [
"es5",
"dom",
"es2015.collection"
]
},
"include": [
"src/**/*.ts",
"src/**/*.tsx"
],
"exclude": [
"node_modules",
"lib"
]
}

View File

@ -0,0 +1,30 @@
{
"extends": "@microsoft/sp-tslint-rules/base-tslint.json",
"rules": {
"class-name": false,
"export-name": false,
"forin": false,
"label-position": false,
"member-access": true,
"no-arg": false,
"no-console": false,
"no-construct": false,
"no-duplicate-variable": true,
"no-eval": false,
"no-function-expression": true,
"no-internal-module": true,
"no-shadowed-variable": true,
"no-switch-case-fall-through": true,
"no-unnecessary-semicolons": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-with-statement": true,
"semicolon": true,
"trailing-comma": false,
"typedef": false,
"typedef-whitespace": false,
"use-named-parameter": true,
"variable-name": false,
"whitespace": false
}
}