Merge branch 'dev-react' of https://github.com/Azure/ServerlessLibrary into master
This commit is contained in:
Коммит
0cdb1a0c0f
|
@ -0,0 +1,25 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>netcoreapp2.1</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\ServerlessLibraryAPI\CosmosLibraryStore.cs" Link="CosmosLibraryStore.cs" />
|
||||
<Compile Include="..\ServerlessLibraryAPI\ILibraryStore.cs" Link="ILibraryStore.cs" />
|
||||
<Compile Include="..\ServerlessLibraryAPI\Models\LibraryItem.cs" Link="LibraryItem.cs" />
|
||||
<Compile Include="..\ServerlessLibraryAPI\ServerlessLibrarySettings.cs" Link="ServerlessLibrarySettings.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="..\ServerlessLibraryAPI\wwwroot\items.json" Link="items.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.3.0" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="4.5.0" />
|
||||
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
|
@ -0,0 +1,135 @@
|
|||
using System;
|
||||
using System.Reflection;
|
||||
using System.IO;
|
||||
using ServerlessLibrary.Models;
|
||||
using System.Collections.Generic;
|
||||
using Newtonsoft.Json;
|
||||
using ServerlessLibrary;
|
||||
using Microsoft.WindowsAzure.Storage.Table;
|
||||
using Microsoft.WindowsAzure.Storage;
|
||||
using Microsoft.WindowsAzure.Storage.RetryPolicies;
|
||||
using System.Threading.Tasks;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibraryStoreMigration
|
||||
{
|
||||
class Program
|
||||
{
|
||||
const string tableName = "slitemstats";
|
||||
static void Main(string[] args)
|
||||
{
|
||||
if (args.Length < 1)
|
||||
{
|
||||
Console.WriteLine("Please enter specify operation to be performed (cosmosdb|stats)");
|
||||
Console.WriteLine("Please note that connection informations need to be provided as environment variables.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (args[0].Equals("cosmosdb", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
MigrateToCosmosDB();
|
||||
}
|
||||
|
||||
if (args[0].Equals("stats", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddNewStatsColumns();
|
||||
}
|
||||
}
|
||||
|
||||
public static void AddNewStatsColumns()
|
||||
{
|
||||
TableRequestOptions tableRequestRetry = new TableRequestOptions { RetryPolicy = new LinearRetry(TimeSpan.FromSeconds(2), 3) };
|
||||
TableQuery<NewSLItemStats> query = new TableQuery<NewSLItemStats>();
|
||||
TableContinuationToken continuationToken = null;
|
||||
List<NewSLItemStats> entities = new List<NewSLItemStats>();
|
||||
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(ServerlessLibrarySettings.SLStorageString);
|
||||
|
||||
CloudTableClient tableClient = storageAccount.CreateCloudTableClient();
|
||||
CloudTable table = tableClient.GetTableReference(tableName);
|
||||
var opContext = new OperationContext();
|
||||
do
|
||||
{
|
||||
TableQuerySegment<NewSLItemStats> queryResults = (table).ExecuteQuerySegmentedAsync(query, continuationToken, tableRequestRetry, opContext).Result;
|
||||
continuationToken = queryResults.ContinuationToken;
|
||||
entities.AddRange(queryResults.Results);
|
||||
} while (continuationToken != null);
|
||||
|
||||
Console.WriteLine(entities.Count);
|
||||
List<LibraryItem> libraryItems = GetAllLibraryItemsFromFile();
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
entity.id = libraryItems.FirstOrDefault(l => l.Template == entity.template).Id;
|
||||
entity.likes = 0;
|
||||
entity.dislikes = 0;
|
||||
TableOperation operation = TableOperation.InsertOrMerge(entity);
|
||||
Task<TableResult> r = table.ExecuteAsync(operation);
|
||||
TableResult a = r.Result;
|
||||
}
|
||||
}
|
||||
|
||||
public static void MigrateToCosmosDB()
|
||||
{
|
||||
var libraryItems = GetAllLibraryItemsFromFile();
|
||||
Console.WriteLine("Number of samples to be migrated from file to cosmos db: {0}", libraryItems.Count);
|
||||
CosmosLibraryStore libraryStore = new CosmosLibraryStore();
|
||||
|
||||
IList<LibraryItem> libraryItemsInCosmos = libraryStore.GetAllItems().Result;
|
||||
Console.WriteLine("Number of samples already present in cosmos db: {0}", libraryItemsInCosmos.Count);
|
||||
|
||||
if (libraryItemsInCosmos.Count != libraryItems.Count)
|
||||
{
|
||||
foreach (LibraryItem libraryItem in libraryItems)
|
||||
{
|
||||
if (!libraryItemsInCosmos.Any(c => c.Id == libraryItem.Id))
|
||||
{
|
||||
Console.WriteLine("Item {0} not present in cosmos db. will be migrated" + libraryItem.Id);
|
||||
try
|
||||
{
|
||||
libraryStore.Add(libraryItem).Wait();
|
||||
Console.WriteLine("Migrated sample with id {0}" + libraryItem.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine("Got exception {0}", ex);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine("Samples are successfully migrated to cosmos db");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Samples are already migrated to cosmos db");
|
||||
}
|
||||
}
|
||||
|
||||
public static List<LibraryItem> GetAllLibraryItemsFromFile()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
using (Stream stream = assembly.GetManifestResourceStream("LibraryStoreMigration.items.json"))
|
||||
using (StreamReader reader = new StreamReader(stream))
|
||||
{
|
||||
string result = reader.ReadToEnd();
|
||||
return JsonConvert.DeserializeObject<List<LibraryItem>>(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public class OldSLItemStats : TableEntity
|
||||
{
|
||||
public string template { get; set; }
|
||||
public int totalDownloads { get; set; }
|
||||
public int downloadsToday { get; set; }
|
||||
public int downloadsThisWeek { get; set; }
|
||||
public int downloadsThisMonth { get; set; }
|
||||
public DateTime lastUpdated { get; set; }
|
||||
}
|
||||
|
||||
public class NewSLItemStats : OldSLItemStats
|
||||
{
|
||||
public string id { get; set; }
|
||||
public int likes { get; set; }
|
||||
public int dislikes { get; set; }
|
||||
}
|
||||
}
|
|
@ -7,6 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServerlessLibraryFunctionAp
|
|||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ServerlessLibraryAPI", "ServerlessLibraryAPI\ServerlessLibraryAPI.csproj", "{9DB5E7FD-1720-4FEC-B6BF-E91BCA659A54}"
|
||||
EndProject
|
||||
Project("{151D2E53-A2C4-4D7D-83FE-D05416EBD58E}") = "ServerlessLibraryLogicApp", "ServerlessLibraryLogicApp\ServerlessLibraryLogicApp.deployproj", "{92DD9A5D-3A93-493C-8F69-23A1C519A1C4}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
|
@ -21,6 +23,10 @@ Global
|
|||
{9DB5E7FD-1720-4FEC-B6BF-E91BCA659A54}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{9DB5E7FD-1720-4FEC-B6BF-E91BCA659A54}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{9DB5E7FD-1720-4FEC-B6BF-E91BCA659A54}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{92DD9A5D-3A93-493C-8F69-23A1C519A1C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{92DD9A5D-3A93-493C-8F69-23A1C519A1C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{92DD9A5D-3A93-493C-8F69-23A1C519A1C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{92DD9A5D-3A93-493C-8F69-23A1C519A1C4}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
|
|
@ -0,0 +1,234 @@
|
|||
/Properties/launchSettings.json
|
||||
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
build/
|
||||
bld/
|
||||
bin/
|
||||
Bin/
|
||||
obj/
|
||||
Obj/
|
||||
|
||||
# Visual Studio 2015 cache/options directory
|
||||
.vs/
|
||||
/wwwroot/dist/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUNIT
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_i.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# JustCode is a .NET coding add-in
|
||||
.JustCode
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# TODO: Comment the next line if you want to checkin your web deploy settings
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/packages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/packages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/packages/repositories.config
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Microsoft Azure ApplicationInsights config file
|
||||
ApplicationInsights.config
|
||||
|
||||
# Windows Store app package directory
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
/node_modules
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
|
@ -1,92 +1,135 @@
|
|||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Newtonsoft.Json;
|
||||
using ServerlessLibrary.Models;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
public interface ICacheService {
|
||||
List<LibraryItem> GetCachedItems();
|
||||
}
|
||||
//https://stackoverflow.com/questions/44723017/in-memory-caching-with-auto-regeneration-on-asp-net-core
|
||||
public class CacheService:ICacheService
|
||||
public interface ICacheService
|
||||
{
|
||||
IList<LibraryItemWithStats> GetCachedItems();
|
||||
}
|
||||
|
||||
//https://stackoverflow.com/questions/44723017/in-memory-caching-with-auto-regeneration-on-asp-net-core
|
||||
public class CacheService : ICacheService
|
||||
{
|
||||
protected readonly IMemoryCache _cache;
|
||||
private readonly ILibraryStore libraryStore;
|
||||
private readonly ILogger logger;
|
||||
|
||||
private Task LoadingTask = Task.CompletedTask;
|
||||
private Timer Timer = null;
|
||||
private bool LoadingBusy = false;
|
||||
private bool isCacheLoadedOnce = false;
|
||||
|
||||
public CacheService(IMemoryCache cache, ILibraryStore libraryStore, ILogger<CacheService> logger)
|
||||
{
|
||||
protected readonly IMemoryCache _cache;
|
||||
private IHostingEnvironment _env;
|
||||
|
||||
public CacheService(IMemoryCache cache, IHostingEnvironment env)
|
||||
{
|
||||
this._cache = cache;
|
||||
this._env = env;
|
||||
InitTimer();
|
||||
}
|
||||
private void InitTimer()
|
||||
{
|
||||
_cache.Set<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY, new LibraryItemsResult() { Result = new List<LibraryItem>(), IsBusy = true });
|
||||
|
||||
Timer = new Timer(TimerTickAsync, null, 1000, ServerlessLibrarySettings.SLCacheRefreshIntervalInSeconds * 1000);
|
||||
}
|
||||
|
||||
public Task LoadingTask = Task.CompletedTask;
|
||||
public Timer Timer { get; set; }
|
||||
public bool LoadingBusy = false;
|
||||
|
||||
public List<LibraryItem> GetCachedItems() {
|
||||
return _cache.Get<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY).Result;
|
||||
}
|
||||
private async void TimerTickAsync(object state)
|
||||
{
|
||||
if (LoadingBusy) return;
|
||||
try
|
||||
{
|
||||
LoadingBusy = true;
|
||||
LoadingTask = LoadCaches();
|
||||
await LoadingTask;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do not crash the app
|
||||
}
|
||||
finally
|
||||
{
|
||||
LoadingBusy = false;
|
||||
}
|
||||
}
|
||||
private async Task LoadCaches()
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await ConstructCache();
|
||||
_cache.Set<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY, new LibraryItemsResult() { Result = items, IsBusy = false });
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
private async Task<List<LibraryItem>> ConstructCache()
|
||||
{
|
||||
var webRoot = _env.WebRootPath;
|
||||
var file = System.IO.Path.Combine(webRoot, "items.json");
|
||||
var stats = await StorageHelper.getSLItemRecordsAsync();
|
||||
var fileContent = JsonConvert.DeserializeObject<List<LibraryItem>>(await System.IO.File.ReadAllTextAsync(file));
|
||||
foreach (var item in fileContent)
|
||||
{
|
||||
var itemStat = item.Template != null ? stats.Where(s => s.template == item.Template.ToString()).FirstOrDefault() : null;
|
||||
item.TotalDownloads = itemStat != null ? itemStat.totalDownloads : 1;
|
||||
item.DownloadsThisMonth = itemStat != null ? itemStat.downloadsThisMonth : 1;
|
||||
item.DownloadsThisWeek = itemStat != null ? itemStat.downloadsThisWeek : 1;
|
||||
item.DownloadsToday = itemStat != null ? itemStat.downloadsToday : 1;
|
||||
item.AuthorTypeDesc = (item.AuthorType == "Microsoft" ? "This has been authored by Microsoft" : "This is a community contribution");
|
||||
}
|
||||
return fileContent;
|
||||
}
|
||||
|
||||
this._cache = cache;
|
||||
this.libraryStore = libraryStore;
|
||||
this.logger = logger;
|
||||
InitTimer();
|
||||
}
|
||||
public class LibraryItemsResult {
|
||||
public List<LibraryItem> Result { get; set; }
|
||||
public bool IsBusy{ get; set; }
|
||||
|
||||
private void InitTimer()
|
||||
{
|
||||
_cache.Set<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY, new LibraryItemsResult() { Result = new List<LibraryItemWithStats>(), IsBusy = true });
|
||||
|
||||
Timer = new Timer(TimerTickAsync, null, 1000, ServerlessLibrarySettings.SLCacheRefreshIntervalInSeconds * 1000);
|
||||
}
|
||||
|
||||
public IList<LibraryItemWithStats> GetCachedItems()
|
||||
{
|
||||
// Make a blocking call to load cache on first time call.
|
||||
if (!isCacheLoadedOnce)
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Loading initial cache");
|
||||
IList<LibraryItemWithStats> items = this.ConstructCache().Result;
|
||||
_cache.Set(ServerlessLibrarySettings.CACHE_ENTRY, new LibraryItemsResult() { Result = items, IsBusy = false });
|
||||
logger.LogInformation("Loaded {0} items into cache", items.Count());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Failed to load cache in first call");
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation("Successfully loaded initial cache");
|
||||
isCacheLoadedOnce = true;
|
||||
return _cache.Get<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY).Result;
|
||||
}
|
||||
|
||||
private async void TimerTickAsync(object state)
|
||||
{
|
||||
logger.LogInformation("Cache refresh timer fired");
|
||||
if (!isCacheLoadedOnce || LoadingBusy)
|
||||
{
|
||||
logger.LogWarning("Skipping cache refresh");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LoadingBusy = true;
|
||||
LoadingTask = LoadCaches();
|
||||
await LoadingTask;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// do not crash the app
|
||||
}
|
||||
finally
|
||||
{
|
||||
LoadingBusy = false;
|
||||
}
|
||||
}
|
||||
private async Task LoadCaches()
|
||||
{
|
||||
try
|
||||
{
|
||||
logger.LogInformation("Starting cache refresh");
|
||||
var items = await ConstructCache();
|
||||
_cache.Set<LibraryItemsResult>(ServerlessLibrarySettings.CACHE_ENTRY, new LibraryItemsResult() { Result = items, IsBusy = false });
|
||||
logger.LogInformation("Updated cache with {0} items", items.Count());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Failed to load cache");
|
||||
}
|
||||
}
|
||||
private async Task<IList<LibraryItemWithStats>> ConstructCache()
|
||||
{
|
||||
logger.LogInformation("Starting ConstructCache");
|
||||
IList<LibraryItem> libraryItems;
|
||||
IList<LibraryItemWithStats> libraryItemsWithStats = new List<LibraryItemWithStats>();
|
||||
libraryItems = await this.libraryStore.GetAllItems();
|
||||
logger.LogInformation("Cosmos DB returned {0} results", libraryItems.Count());
|
||||
var stats = await StorageHelper.getSLItemRecordsAsync();
|
||||
logger.LogInformation("Storage returned {0} results", stats.Count());
|
||||
foreach (var storeItem in libraryItems)
|
||||
{
|
||||
var item = storeItem.ConvertTo<LibraryItemWithStats>();
|
||||
var itemStat = stats.Where(s => s.id == storeItem.Id.ToString()).FirstOrDefault();
|
||||
item.TotalDownloads = itemStat != null && itemStat.totalDownloads > 0 ? itemStat.totalDownloads : 1;
|
||||
item.Likes = itemStat != null && itemStat.likes > 0 ? itemStat.likes : 0;
|
||||
item.Dislikes = itemStat != null && itemStat.dislikes > 0 ? itemStat.dislikes : 0;
|
||||
libraryItemsWithStats.Add(item);
|
||||
}
|
||||
|
||||
logger.LogInformation("ConstructCache returned {0} items", libraryItemsWithStats.Count());
|
||||
return libraryItemsWithStats;
|
||||
}
|
||||
}
|
||||
|
||||
public class LibraryItemsResult
|
||||
{
|
||||
public IList<LibraryItemWithStats> Result { get; set; }
|
||||
public bool IsBusy { get; set; }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
REACT_APP_INSTRUMENTATION_KEY=
|
|
@ -0,0 +1 @@
|
|||
REACT_APP_INSTRUMENTATION_KEY=d35b5caf-a276-467c-9ac7-f7f7d84ea171
|
|
@ -0,0 +1,23 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
|
@ -0,0 +1,68 @@
|
|||
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
|
||||
|
||||
## Available Scripts
|
||||
|
||||
In the project directory, you can run:
|
||||
|
||||
### `npm start`
|
||||
|
||||
Runs the app in the development mode.<br>
|
||||
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
|
||||
|
||||
The page will reload if you make edits.<br>
|
||||
You will also see any lint errors in the console.
|
||||
|
||||
### `npm test`
|
||||
|
||||
Launches the test runner in the interactive watch mode.<br>
|
||||
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
|
||||
|
||||
### `npm run build`
|
||||
|
||||
Builds the app for production to the `build` folder.<br>
|
||||
It correctly bundles React in production mode and optimizes the build for the best performance.
|
||||
|
||||
The build is minified and the filenames include the hashes.<br>
|
||||
Your app is ready to be deployed!
|
||||
|
||||
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
|
||||
|
||||
### `npm run eject`
|
||||
|
||||
**Note: this is a one-way operation. Once you `eject`, you can’t go back!**
|
||||
|
||||
If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
|
||||
|
||||
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own.
|
||||
|
||||
You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it.
|
||||
|
||||
## Learn More
|
||||
|
||||
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
|
||||
|
||||
To learn React, check out the [React documentation](https://reactjs.org/).
|
||||
|
||||
### Code Splitting
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting
|
||||
|
||||
### Analyzing the Bundle Size
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size
|
||||
|
||||
### Making a Progressive Web App
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app
|
||||
|
||||
### Advanced Configuration
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration
|
||||
|
||||
### Deployment
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/deployment
|
||||
|
||||
### `npm run build` fails to minify
|
||||
|
||||
This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "ServerlessLibraryAPI",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@uifabric/styling": "^6.47.6",
|
||||
"markdown-to-jsx": "^6.10.1",
|
||||
"node-sass": "^4.12.0",
|
||||
"office-ui-fabric-react": "^6.187.0",
|
||||
"react": "^16.8.6",
|
||||
"react-app-polyfill": "^1.0.1",
|
||||
"react-dom": "^16.8.6",
|
||||
"react-redux": "^6.0.1",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"react-scripts": "3.0.0",
|
||||
"redux": "^4.0.1",
|
||||
"rimraf": "^2.6.3"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "rimraf ./build && react-scripts start",
|
||||
"build": "react-scripts build",
|
||||
"test": "react-scripts test --env=jsdom",
|
||||
"eject": "react-scripts eject"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
},
|
||||
"browserslist": [
|
||||
">0.2%",
|
||||
"ie 11",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
]
|
||||
}
|
До Ширина: | Высота: | Размер: 8.0 KiB После Ширина: | Высота: | Размер: 8.0 KiB |
|
@ -0,0 +1,54 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||
<meta name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<base href="%PUBLIC_URL%/" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>Azure Serverless Community Library</title>
|
||||
<script type="text/javascript">
|
||||
var aiInstrumentationKey="%REACT_APP_INSTRUMENTATION_KEY%";
|
||||
if (aiInstrumentationKey !== "")
|
||||
{
|
||||
var appInsights=window.appInsights||function(a){
|
||||
function b(a){c[a]=function(){var b=arguments;c.queue.push(function(){c[a].apply(c,b)})}}var c={config:a},d=document,e=window;setTimeout(function(){var b=d.createElement("script");b.src=a.url||"https://az416426.vo.msecnd.net/scripts/a/ai.0.js",d.getElementsByTagName("script")[0].parentNode.appendChild(b)});try{c.cookie=d.cookie}catch(a){}c.queue=[];for(var f=["Event","Exception","Metric","PageView","Trace","Dependency"];f.length;)b("track"+f.pop());if(b("setAuthenticatedUserContext"),b("clearAuthenticatedUserContext"),b("startTrackEvent"),b("stopTrackEvent"),b("startTrackPage"),b("stopTrackPage"),b("flush"),!a.disableExceptionTracking){f="onerror",b("_"+f);var g=e[f];e[f]=function(a,b,d,e,h){var i=g&&g(a,b,d,e,h);return!0!==i&&c["_"+f](a,b,d,e,h),i}}return c
|
||||
}({
|
||||
instrumentationKey: aiInstrumentationKey
|
||||
});
|
||||
window.appInsights=appInsights,appInsights.queue&&0===appInsights.queue.length&&appInsights.trackPageView();
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"short_name": "ServerlessLibraryAPI",
|
||||
"name": "ServerlessLibraryAPI",
|
||||
"icons": [
|
||||
{
|
||||
"src": "favicon.ico",
|
||||
"sizes": "64x64 32x32 24x24 16x16",
|
||||
"type": "image/x-icon"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"theme_color": "#000000",
|
||||
"background_color": "#ffffff"
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
/* #container { background: blue; } */
|
||||
#header {
|
||||
height: 40px;
|
||||
}
|
||||
#main {
|
||||
height: calc(100% - 40px);
|
||||
}
|
||||
#container {
|
||||
overflow-y: auto;
|
||||
}
|
|
@ -0,0 +1,103 @@
|
|||
import React, { Component } from "react";
|
||||
import { Switch, Route, withRouter } from "react-router-dom";
|
||||
import { connect } from "react-redux";
|
||||
import { Dialog } from "office-ui-fabric-react";
|
||||
|
||||
import "./App.css";
|
||||
|
||||
import Main from "./components/Main/Main";
|
||||
import Header from "./components/Header/Header";
|
||||
import DetailView from "./components/DetailView/DetailView";
|
||||
import ContributionsPage from "./components/Contribute/Contribute";
|
||||
import { sampleActions } from "./actions/sampleActions";
|
||||
import { userActions } from "./actions/userActions";
|
||||
import { libraryService, userService } from "./services";
|
||||
|
||||
const loginErrorMsg = "We were unable to log you in. Please try again later.";
|
||||
|
||||
class App extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
showErrorDialog: false
|
||||
};
|
||||
this.onDismissErrorDialog = this.onDismissErrorDialog.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
libraryService
|
||||
.getAllSamples()
|
||||
.then(samples => this.props.getSamplesSuccess(samples))
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
|
||||
this.props.getCurrentUserRequest();
|
||||
userService
|
||||
.getCurrentUser()
|
||||
.then(user => this.props.getCurrentUserSuccess(user))
|
||||
.catch(data => {
|
||||
this.props.getCurrentUserFailure();
|
||||
if (data.status !== 401) {
|
||||
this.setState({
|
||||
showErrorDialog: true
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onDismissErrorDialog() {
|
||||
this.setState({
|
||||
showErrorDialog: false
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { showErrorDialog } = this.state;
|
||||
return (
|
||||
<div id="container">
|
||||
<div id="header">
|
||||
<Header />
|
||||
</div>
|
||||
<div id="main">
|
||||
<Switch>
|
||||
<Route exact path="/" component={Main} />
|
||||
<Route path="/sample/:id" component={DetailView} />
|
||||
<Route exact path="/contribute" component={ContributionsPage} />
|
||||
</Switch>
|
||||
</div>
|
||||
{showErrorDialog && (
|
||||
<Dialog
|
||||
dialogContentProps={{
|
||||
title: "An error occurred!"
|
||||
}}
|
||||
hidden={!showErrorDialog}
|
||||
onDismiss={this.onDismissErrorDialog}
|
||||
>
|
||||
<p>{loginErrorMsg}</p>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getSamplesSuccess: sampleActions.getSamplesSuccess,
|
||||
getCurrentUserRequest: userActions.getCurrentUserRequest,
|
||||
getCurrentUserSuccess: userActions.getCurrentUserSuccess,
|
||||
getCurrentUserFailure: userActions.getCurrentUserFailure
|
||||
};
|
||||
const AppContainer = withRouter(
|
||||
connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(App)
|
||||
);
|
||||
|
||||
export default AppContainer;
|
|
@ -0,0 +1,10 @@
|
|||
export const sampleActionTypes = {
|
||||
GETSAMPLES_SUCCESS: "GETSAMPLES_SUCCESS"
|
||||
};
|
||||
|
||||
export const userActionTypes = {
|
||||
GETCURRENTUSER_REQUEST: "GETCURRENTUSER_REQUEST",
|
||||
GETCURRENTUSER_SUCCESS: "GETCURRENTUSER_SUCCESS",
|
||||
GETCURRENTUSER_FAILURE: "GETCURRENTUSER_FAILURE",
|
||||
LOGOUT: "LOGOUT"
|
||||
};
|
|
@ -0,0 +1,12 @@
|
|||
import { sampleActionTypes } from "./actionTypes";
|
||||
|
||||
export const sampleActions = {
|
||||
getSamplesSuccess
|
||||
};
|
||||
|
||||
function getSamplesSuccess(samples) {
|
||||
return {
|
||||
type: sampleActionTypes.GETSAMPLES_SUCCESS,
|
||||
samples
|
||||
};
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import { userActionTypes } from "./actionTypes";
|
||||
|
||||
export const userActions = {
|
||||
getCurrentUserRequest,
|
||||
getCurrentUserSuccess,
|
||||
getCurrentUserFailure,
|
||||
logout
|
||||
};
|
||||
|
||||
function getCurrentUserRequest(user) {
|
||||
return {
|
||||
type: userActionTypes.GETCURRENTUSER_REQUEST,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentUserSuccess(user) {
|
||||
return {
|
||||
type: userActionTypes.GETCURRENTUSER_SUCCESS,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentUserFailure(user) {
|
||||
return {
|
||||
type: userActionTypes.GETCURRENTUSER_FAILURE,
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
function logout() {
|
||||
return {
|
||||
type: userActionTypes.LOGOUT
|
||||
};
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="15" height="16" viewBox="0 0 15 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13 6.32812V13.0625L7.5 15.8125L2 13.0625V6.33594L0.601562 2.49219L4.75 4.5625L7.09375 3.39844L8.21094 0.046875L14.1094 3L13 6.32812ZM8.10938 3.5L12.2109 5.54688L12.8906 3.50781L8.78906 1.45312L8.10938 3.5ZM5.86719 5.125L6.90625 5.64062L7.78125 8.05469L11.3828 6.25L7.5 4.3125L5.86719 5.125ZM2.39844 4.50781L2.90625 5.89062L6.60156 7.74219L6.09375 6.35938L2.39844 4.50781ZM3 12.4453L7 14.4453V9.0625L3 7.0625V12.4453ZM12 12.4453V7.0625L8 9.0625V14.4453L12 12.4453Z"/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 568 B |
|
@ -0,0 +1,28 @@
|
|||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 64 54"
|
||||
enableBackground="new 0 -10 64 54"
|
||||
xmlSpace="preserve"
|
||||
>
|
||||
<g>
|
||||
<path
|
||||
fill="#3999C6"
|
||||
d="M63.6,32.4c0.6-0.6,0.5-1.7,0-2.3L60.5,27L46.7,13.6c-0.6-0.6-1.5-0.6-2.2,0l0,0c-0.6,0.6-0.8,1.7,0,2.3 L59,30.1c0.6,0.6,0.6,1.7,0,2.3L44.2,47.1c-0.6,0.6-0.6,1.7,0,2.3l0,0c0.6,0.6,1.7,0.5,2.2,0l13.7-13.6c0,0,0,0,0.1-0.1L63.6,32.4z"
|
||||
/>
|
||||
<path
|
||||
fill="#3999C6"
|
||||
d="M0.4,32.4c-0.6-0.6-0.5-1.7,0-2.3L3.5,27l13.8-13.4c0.6-0.6,1.5-0.6,2.2,0l0,0c0.6,0.6,0.8,1.7,0,2.3 L5.3,30.1c-0.6,0.6-0.6,1.7,0,2.3l14.5,14.7c0.6,0.6,0.6,1.7,0,2.3l0,0c-0.6,0.6-1.7,0.5-2.2,0L3.6,36c0,0,0,0-0.1-0.1L0.4,32.4z"
|
||||
/>
|
||||
<polygon
|
||||
fill="#FCD116"
|
||||
points="47.6,2.5 28.1,2.5 17.6,32.1 30.4,32.2 20.4,61.5 48,22.4 34.6,22.4 "
|
||||
/>
|
||||
<polygon
|
||||
opacity="0.3"
|
||||
fill="#FF8C00"
|
||||
enableBackground="new "
|
||||
points="34.6,22.4 47.6,2.5 37.4,2.5 26.6,27.1 39.4,27.2 20.4,61.5 48,22.4 "
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 1.2 KiB |
|
@ -0,0 +1,10 @@
|
|||
<svg
|
||||
width="13"
|
||||
height="12"
|
||||
viewBox="0 0 13 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.5 0.146484C7.05078 0.146484 7.58203 0.21875 8.09375 0.363281C8.60547 0.503906 9.08203 0.705078 9.52344 0.966797C9.96875 1.22852 10.373 1.54297 10.7363 1.91016C11.1035 2.27344 11.418 2.67773 11.6797 3.12305C11.9414 3.56445 12.1426 4.04102 12.2832 4.55273C12.4277 5.06445 12.5 5.5957 12.5 6.14648C12.5 6.79102 12.4004 7.41211 12.2012 8.00977C12.0059 8.60742 11.7285 9.15625 11.3691 9.65625C11.0098 10.1562 10.5781 10.5957 10.0742 10.9746C9.57031 11.3496 9.01172 11.6387 8.39844 11.8418C8.38672 11.8457 8.36914 11.8496 8.3457 11.8535C8.32227 11.8535 8.30469 11.8535 8.29297 11.8535C8.19922 11.8535 8.125 11.8262 8.07031 11.7715C8.01562 11.7168 7.98828 11.6445 7.98828 11.5547C7.98828 11.2773 7.98828 11.0039 7.98828 10.7344C7.99219 10.4609 7.99414 10.1855 7.99414 9.9082C7.99414 9.70898 7.96484 9.50781 7.90625 9.30469C7.84766 9.10156 7.74219 8.93164 7.58984 8.79492C8.04297 8.74414 8.43945 8.65234 8.7793 8.51953C9.12305 8.38281 9.4082 8.19531 9.63477 7.95703C9.86523 7.71875 10.0371 7.42578 10.1504 7.07812C10.2676 6.72656 10.3262 6.3125 10.3262 5.83594C10.3262 5.53125 10.2754 5.24414 10.1738 4.97461C10.0723 4.70117 9.91797 4.45117 9.71094 4.22461C9.75391 4.11523 9.78516 4.00195 9.80469 3.88477C9.82422 3.76758 9.83398 3.65039 9.83398 3.5332C9.83398 3.38086 9.81641 3.23047 9.78125 3.08203C9.75 2.92969 9.70703 2.78125 9.65234 2.63672C9.63281 2.62891 9.61133 2.625 9.58789 2.625C9.56445 2.625 9.54297 2.625 9.52344 2.625C9.39844 2.625 9.26758 2.64648 9.13086 2.68945C8.99414 2.72852 8.85742 2.7793 8.7207 2.8418C8.58789 2.90039 8.45898 2.9668 8.33398 3.04102C8.20898 3.11523 8.09766 3.18555 8 3.25195C7.51172 3.11523 7.01172 3.04688 6.5 3.04688C5.98828 3.04688 5.48828 3.11523 5 3.25195C4.90234 3.18555 4.79102 3.11523 4.66602 3.04102C4.54102 2.9668 4.41016 2.90039 4.27344 2.8418C4.14062 2.7793 4.00391 2.72852 3.86328 2.68945C3.72656 2.64648 3.59766 2.625 3.47656 2.625C3.45703 2.625 3.43555 2.625 3.41211 2.625C3.38867 2.625 3.36719 2.62891 3.34766 2.63672C3.29297 2.78125 3.24805 2.92969 3.21289 3.08203C3.18164 3.23047 3.16602 3.38086 3.16602 3.5332C3.16602 3.65039 3.17578 3.76758 3.19531 3.88477C3.21484 4.00195 3.24609 4.11523 3.28906 4.22461C3.08203 4.45117 2.92773 4.70117 2.82617 4.97461C2.72461 5.24414 2.67383 5.53125 2.67383 5.83594C2.67383 6.3125 2.73047 6.72461 2.84375 7.07227C2.96094 7.41992 3.13281 7.71484 3.35938 7.95703C3.58984 8.19531 3.875 8.38281 4.21484 8.51953C4.55469 8.65625 4.95117 8.75 5.4043 8.80078C5.29102 8.90234 5.20312 9.02539 5.14062 9.16992C5.08203 9.31055 5.04297 9.45508 5.02344 9.60352C4.91797 9.6543 4.80664 9.69336 4.68945 9.7207C4.57227 9.74805 4.45508 9.76172 4.33789 9.76172C4.08789 9.76172 3.88086 9.70312 3.7168 9.58594C3.55273 9.46875 3.40625 9.30859 3.27734 9.10547C3.23047 9.03125 3.17383 8.95703 3.10742 8.88281C3.04102 8.80859 2.96875 8.74219 2.89062 8.68359C2.8125 8.625 2.72852 8.57812 2.63867 8.54297C2.54883 8.50391 2.45508 8.48438 2.35742 8.48438C2.3418 8.48438 2.31836 8.48633 2.28711 8.49023C2.25586 8.49023 2.22461 8.49414 2.19336 8.50195C2.16602 8.50977 2.14062 8.52148 2.11719 8.53711C2.09375 8.55273 2.08203 8.57227 2.08203 8.5957C2.08203 8.64258 2.10938 8.68945 2.16406 8.73633C2.21875 8.7793 2.26367 8.8125 2.29883 8.83594L2.31641 8.84766C2.40234 8.91406 2.47656 8.97852 2.53906 9.04102C2.60547 9.09961 2.66406 9.16406 2.71484 9.23438C2.76562 9.30078 2.81055 9.375 2.84961 9.45703C2.89258 9.53516 2.9375 9.625 2.98438 9.72656C3.11719 10.0312 3.30273 10.2539 3.54102 10.3945C3.7832 10.5312 4.07031 10.5996 4.40234 10.5996C4.50391 10.5996 4.60547 10.5938 4.70703 10.582C4.80859 10.5664 4.91016 10.5488 5.01172 10.5293V11.5488C5.01172 11.6426 4.98242 11.7168 4.92383 11.7715C4.86914 11.8262 4.79492 11.8535 4.70117 11.8535C4.68945 11.8535 4.67188 11.8535 4.64844 11.8535C4.62891 11.8496 4.61328 11.8457 4.60156 11.8418C3.98828 11.6426 3.42969 11.3555 2.92578 10.9805C2.42188 10.6016 1.99023 10.1621 1.63086 9.66211C1.27148 9.1582 0.992188 8.60742 0.792969 8.00977C0.597656 7.41211 0.5 6.79102 0.5 6.14648C0.5 5.5957 0.570312 5.06445 0.710938 4.55273C0.855469 4.04102 1.05859 3.56445 1.32031 3.12305C1.58203 2.67773 1.89453 2.27344 2.25781 1.91016C2.625 1.54297 3.0293 1.22852 3.4707 0.966797C3.91602 0.705078 4.39453 0.503906 4.90625 0.363281C5.41797 0.21875 5.94922 0.146484 6.5 0.146484Z"
|
||||
/>
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 4.3 KiB |
|
@ -0,0 +1,21 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 50 51.8">
|
||||
|
||||
<g>
|
||||
|
||||
<path fill="#59b4d9" d="M26 21.3v-5h-2.5v5c0 .9-.8 1.7-2.1 2.1l-3.4.7c-2.3.7-3.9 2.5-3.9 4.5v5.7h2.5v-5.7c0-.9.8-1.7 2.1-2.1l3.4-.8c2.3-.6 3.9-2.4 3.9-4.4z"></path>
|
||||
|
||||
<path fill="#7fba00" d="M19.6 36.9v-4.6c0-1.1-.9-2-2-2H13c-1.1 0-2 .9-2 2v4.6c0 1.1.9 2 2 2h4.6c1.1 0 2-.9 2-2z"></path>
|
||||
|
||||
<path fill="#59b4d9" d="M23.2 21.3v-5h2.5v5c0 .9.8 1.7 2.1 2.1l4.2.9c2.3.7 3.9 2.5 3.9 4.5v5.7h-2.5v-5.7c0-.9-.8-1.7-2.1-2.1l-4.2-.9c-2.3-.7-3.9-2.5-3.9-4.5z"></path>
|
||||
|
||||
<path fill="#7fba00" d="M30.4 37.1v-4.6c0-1.1.9-2 2-2H37c1.1 0 2 .9 2 2v4.6c0 1.1-.9 2-2 2h-4.6c-1.1 0-2-.9-2-2z"></path>
|
||||
|
||||
<path fill="#59b4d9" d="M23.2 16.3H26v4.8h-2.8z"></path>
|
||||
|
||||
<path fill="#0072c6" d="M26.5 11.7v3.7h-3.7v-3.7h3.7m.8-2.8H22c-1.1 0-2 .9-2 2v5.3c0 1.1.9 2 2 2h5.3c1.1 0 2-.9 2-2v-5.3c0-1.1-.9-2-2-2z"></path>
|
||||
|
||||
<path fill="#59b4d9" d="M7.9 44.4c-2.1 0-3.6-.4-4.5-1.1-.9-.8-1.3-2.1-1.3-4V28.9c0-1.7-.7-2.6-2.1-2.6v-2.6c1.4 0 2.1-.9 2.1-2.7V10.8c0-1.9.4-3.3 1.3-4.1s2.4-1.1 4.5-1.1v2.6c-1.5 0-2.3.8-2.3 2.5v10c0 2.3-.7 3.7-2.2 4.3 1.4.6 2.2 2 2.2 4.3v9.9c0 .9.2 1.6.5 2 .4.4.9.6 1.7.6l.1 2.6c-.1 0 0 0 0 0zM42.1 5.6c2.1 0 3.6.4 4.5 1.1.9.8 1.3 2.1 1.3 4v10.4c0 1.7.7 2.6 2.1 2.6v2.6c-1.4 0-2.1.9-2.1 2.7v10.1c0 1.9-.4 3.3-1.3 4.1-.9.8-2.4 1.2-4.5 1.2v-2.6c1.5 0 2.3-.8 2.3-2.5v-10c0-2.3.7-3.7 2.2-4.3-1.4-.6-2.2-2-2.2-4.3v-9.9c0-.9-.2-1.6-.5-2-.4-.4-.9-.6-1.7-.6l-.1-2.6z"></path>
|
||||
|
||||
</g>
|
||||
|
||||
</svg>
|
После Ширина: | Высота: | Размер: 1.5 KiB |
|
@ -0,0 +1,35 @@
|
|||
.content-header-titlewapper {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.content-header-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.content-header-contributionLink {
|
||||
text-decoration: none;
|
||||
}
|
||||
.contributionLink-content {
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.contribution-link-text {
|
||||
margin-left: 12px;
|
||||
}
|
||||
.content-header-sortbywrappper {
|
||||
display: flex;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.content-header-count {
|
||||
text-align: left;
|
||||
margin-top: auto;
|
||||
margin-bottom: auto;
|
||||
color: #000000;
|
||||
font-size: 16px;
|
||||
line-height: normal;
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import {
|
||||
SearchBox,
|
||||
Dropdown,
|
||||
Icon,
|
||||
Link as FabricLink
|
||||
} from "office-ui-fabric-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
paramsToQueryString,
|
||||
queryStringToParams,
|
||||
trackEvent
|
||||
} from "../../helpers";
|
||||
import "./ContentHeader.css";
|
||||
|
||||
class ContentHeader extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filterText: this.props.initialSearchText,
|
||||
sortby: this.props.initialSortBy
|
||||
};
|
||||
}
|
||||
|
||||
filterTextChanged(newValue) {
|
||||
var params = queryStringToParams(this.props.location.search);
|
||||
delete params["filtertext"];
|
||||
if (newValue && newValue !== "") {
|
||||
params["filtertext"] = newValue;
|
||||
}
|
||||
|
||||
this.setState({ filterText: newValue });
|
||||
this.props.history.push(paramsToQueryString(params));
|
||||
trackEvent("/filter/change/searchtext", newValue);
|
||||
}
|
||||
|
||||
sortbyChanged(newValue) {
|
||||
var params = queryStringToParams(this.props.location.search);
|
||||
delete params["sortby"];
|
||||
if (newValue !== "totaldownloads") {
|
||||
params["sortby"] = newValue;
|
||||
}
|
||||
this.setState({ sortby: newValue });
|
||||
this.props.history.push(paramsToQueryString(params));
|
||||
trackEvent("/sortby/change", newValue);
|
||||
}
|
||||
|
||||
render() {
|
||||
const dropdownStyles = () => {
|
||||
return {
|
||||
root: {
|
||||
display: "flex"
|
||||
},
|
||||
label: {
|
||||
marginRight: "10px",
|
||||
color: "#000000",
|
||||
fontSize: "12px"
|
||||
},
|
||||
title: {
|
||||
color: "#595959;",
|
||||
border: "1px solid #BCBCBC",
|
||||
borderRadius: "2px",
|
||||
fontSize: "12px"
|
||||
},
|
||||
dropdown: {
|
||||
width: 150
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const searchBoxStyles = () => {
|
||||
return {
|
||||
root: {
|
||||
border: "1px solid #BCBCBC",
|
||||
borderRadius: "3px"
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
let resultCount = this.props.samples.length;
|
||||
return (
|
||||
<div className="content-header">
|
||||
<div className="content-header-titlewapper">
|
||||
<div className="content-header-title">
|
||||
Azure serverless community library
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<FabricLink
|
||||
as={Link}
|
||||
to="/contribute"
|
||||
className="content-header-contributionLink"
|
||||
>
|
||||
<div className="contributionLink-content">
|
||||
<Icon iconName="contribution-svg" />
|
||||
<div className="contribution-link-text">Contributions</div>
|
||||
</div>
|
||||
</FabricLink>
|
||||
</div>
|
||||
</div>
|
||||
<SearchBox
|
||||
placeholder="Search"
|
||||
value={this.state.filterText}
|
||||
onSearch={newValue => this.filterTextChanged(newValue)}
|
||||
onClear={() => this.filterTextChanged("")}
|
||||
styles={searchBoxStyles}
|
||||
/>
|
||||
<div className="content-header-sortbywrappper">
|
||||
<div className="content-header-count">
|
||||
Displaying {resultCount} {resultCount === 1 ? "result" : "results"}
|
||||
</div>
|
||||
<div style={{ marginLeft: "auto" }}>
|
||||
<Dropdown
|
||||
defaultSelectedKey={this.state.sortby}
|
||||
options={[
|
||||
{ key: "totaldownloads", text: "Most downloads" },
|
||||
{ key: "atoz", text: "A to Z" },
|
||||
{ key: "createddate", text: "Most recent" }
|
||||
]}
|
||||
label="Sort By"
|
||||
styles={dropdownStyles}
|
||||
onChange={(ev, item) => this.sortbyChanged(item.key)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({});
|
||||
|
||||
const ContentHeaderContainer = connect(mapStateToProps)(ContentHeader);
|
||||
|
||||
export default withRouter(ContentHeaderContainer);
|
|
@ -0,0 +1,28 @@
|
|||
import React, { Component } from "react";
|
||||
import ContributionForm from "./ContributionForm";
|
||||
import ContributionsList from "./ContributionsList";
|
||||
import SignInDialog from "./SignInDialog";
|
||||
import PageHeaderWithBackButton from "../shared/PageHeaderWithBackButton";
|
||||
|
||||
import "./Contribute.scss";
|
||||
|
||||
class ContributionsPage extends Component {
|
||||
render() {
|
||||
return (
|
||||
<div className="contribute-page-container">
|
||||
<PageHeaderWithBackButton title="Contributions">
|
||||
<p className="contribute-page-description">
|
||||
This is where you can see all your existing contributions. You can
|
||||
also add a new contribution by clicking on the add new contribution
|
||||
link.
|
||||
</p>
|
||||
</PageHeaderWithBackButton>
|
||||
<ContributionForm />
|
||||
<ContributionsList />
|
||||
<SignInDialog />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ContributionsPage;
|
|
@ -0,0 +1,52 @@
|
|||
.left-margin {
|
||||
margin-left: 23px;
|
||||
}
|
||||
|
||||
.contribute-page-description {
|
||||
max-width: 785px;
|
||||
}
|
||||
|
||||
.add-contribution-container {
|
||||
margin-top: 23px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.add-contribution-link {
|
||||
@extend .left-margin;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.contribution-form-container {
|
||||
background-color: #fbfbfb;
|
||||
margin-top: 12px;
|
||||
padding-top: 15px;
|
||||
padding-left: 23px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex: 1;
|
||||
}
|
||||
.contribution-form-fields-container {
|
||||
max-width: 430px;
|
||||
margin-right: 100px;
|
||||
flex: 1;
|
||||
}
|
||||
.input-field-container {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contribution-form-actions-container {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.contribution-list-container {
|
||||
@extend .left-margin;
|
||||
max-width: 746px;
|
||||
}
|
||||
|
||||
.empty-contribution-list-container {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
|
@ -0,0 +1,317 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
Icon,
|
||||
Link,
|
||||
TextField,
|
||||
PrimaryButton,
|
||||
DefaultButton,
|
||||
Dropdown,
|
||||
Dialog
|
||||
} from "office-ui-fabric-react";
|
||||
|
||||
import { libraryService } from "../../services";
|
||||
import * as commonStyles from "../shared/Button.styles";
|
||||
import * as Constants from "../shared/Constants";
|
||||
|
||||
const initialState = {
|
||||
showForm: false,
|
||||
title: "",
|
||||
description: "",
|
||||
repository: "",
|
||||
template: "",
|
||||
technologies: [],
|
||||
language: "",
|
||||
solutionareas: [],
|
||||
dialogProps: {
|
||||
isVisible: false,
|
||||
title: "",
|
||||
content: {}
|
||||
}
|
||||
};
|
||||
|
||||
class ContributionForm extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = initialState;
|
||||
|
||||
this.onLinkClick = this.onLinkClick.bind(this);
|
||||
this.onAddButtonClick = this.onAddButtonClick.bind(this);
|
||||
this.onCancelButtonClick = this.onCancelButtonClick.bind(this);
|
||||
this.handleInputChange = this.handleInputChange.bind(this);
|
||||
this.onTitleChange = this.onTitleChange.bind(this);
|
||||
this.onDescriptionChange = this.onDescriptionChange.bind(this);
|
||||
this.onDismissDialog = this.onDismissDialog.bind(this);
|
||||
this.validateForm = this.getFormValidationErrors.bind(this);
|
||||
}
|
||||
|
||||
technologiesOptionsChanged(newValue) {
|
||||
let technologies = this.state.technologies;
|
||||
if (newValue.selected) {
|
||||
technologies.push(newValue.key);
|
||||
} else {
|
||||
technologies = technologies.filter(t => t !== newValue.key);
|
||||
}
|
||||
|
||||
this.setState({ technologies: technologies });
|
||||
}
|
||||
|
||||
solutionAreasOptionsChanged(newValue) {
|
||||
let solutionAreas = this.state.solutionareas;
|
||||
if (newValue.selected) {
|
||||
solutionAreas.push(newValue.key);
|
||||
} else {
|
||||
solutionAreas = solutionAreas.filter(t => t !== newValue.key);
|
||||
}
|
||||
|
||||
this.setState({ solutionareas: solutionAreas });
|
||||
}
|
||||
|
||||
languageOptionChanged(newValue) {
|
||||
this.setState({ language: newValue });
|
||||
}
|
||||
|
||||
handleInputChange(event, newValue) {
|
||||
const target = event.target;
|
||||
const name = target.name;
|
||||
this.setState({ [name]: newValue });
|
||||
}
|
||||
|
||||
onTitleChange(event, newValue) {
|
||||
if (!newValue || newValue.length <= 80) {
|
||||
this.setState({ title: newValue || "" });
|
||||
} else {
|
||||
// this block is needed because of Fabric bug #1350
|
||||
this.setState({ title: this.state.title });
|
||||
}
|
||||
}
|
||||
|
||||
onDescriptionChange(event, newValue) {
|
||||
if (!newValue || newValue.length <= 300) {
|
||||
this.setState({ description: newValue || "" });
|
||||
} else {
|
||||
// this block is needed because of Fabric bug #1350
|
||||
this.setState({ description: this.state.description });
|
||||
}
|
||||
}
|
||||
|
||||
getFormValidationErrors(sample) {
|
||||
let errors = [];
|
||||
if (sample.title.length === 0) {
|
||||
errors.push("Title cannot be empty");
|
||||
}
|
||||
if (sample.repository.length === 0) {
|
||||
errors.push("Repository URL cannot be empty");
|
||||
}
|
||||
if (sample.description.length === 0) {
|
||||
errors.push("Description cannot be empty");
|
||||
}
|
||||
if (sample.technologies.length === 0) {
|
||||
errors.push("At least one technology must be selected");
|
||||
}
|
||||
if (sample.language.length === 0) {
|
||||
errors.push("Language must be selected");
|
||||
}
|
||||
if (sample.solutionareas.length === 0) {
|
||||
errors.push("At least one solution area must be selected");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
onLinkClick() {
|
||||
this.setState({ showForm: true });
|
||||
}
|
||||
|
||||
onAddButtonClick() {
|
||||
const sample = {
|
||||
title: this.state.title,
|
||||
description: this.state.description,
|
||||
repository: this.state.repository,
|
||||
template: this.state.template,
|
||||
technologies: this.state.technologies,
|
||||
language: this.state.language,
|
||||
solutionareas: this.state.solutionareas
|
||||
};
|
||||
const errors = this.getFormValidationErrors(sample);
|
||||
if (errors.length > 0) {
|
||||
this.showDialog("Unable to add sample", errors);
|
||||
return;
|
||||
}
|
||||
|
||||
libraryService
|
||||
.submitNewSample(sample)
|
||||
.then(sample => {
|
||||
this.resetForm();
|
||||
this.showDialog(
|
||||
"Thank you!",
|
||||
"Thank you for your contribution! Your sample has been submitted for approval."
|
||||
);
|
||||
})
|
||||
.catch(data => {
|
||||
this.showDialog("Unable to add sample", data.error);
|
||||
});
|
||||
}
|
||||
|
||||
showDialog(title, content) {
|
||||
const dialogProps = {
|
||||
title: title,
|
||||
content: content,
|
||||
isVisible: true
|
||||
};
|
||||
this.setState({ dialogProps });
|
||||
}
|
||||
|
||||
onDismissDialog() {
|
||||
const dialogProps = { ...this.state.dialogProps, isVisible: false };
|
||||
this.setState({ dialogProps });
|
||||
}
|
||||
|
||||
onCancelButtonClick() {
|
||||
this.resetForm();
|
||||
}
|
||||
|
||||
resetForm() {
|
||||
this.setState(initialState);
|
||||
}
|
||||
|
||||
render() {
|
||||
let technologiesOptions = Constants.technologies.map(t => ({
|
||||
key: t,
|
||||
text: t
|
||||
}));
|
||||
let languageOptions = Constants.languages.map(l => ({ key: l, text: l }));
|
||||
languageOptions.push({
|
||||
key: Constants.NotApplicableLanguage,
|
||||
text: "Not applicable"
|
||||
});
|
||||
let solutionAreasOptions = Constants.solutionAreas.map(s => ({
|
||||
key: s,
|
||||
text: s
|
||||
}));
|
||||
solutionAreasOptions.push({
|
||||
key: Constants.OtherSolutionArea,
|
||||
text: Constants.OtherSolutionArea
|
||||
});
|
||||
let { showForm, dialogProps } = this.state;
|
||||
return (
|
||||
<div className="add-contribution-container">
|
||||
<div className="add-contribution-link">
|
||||
<Link onClick={this.onLinkClick}>
|
||||
<Icon iconName="Edit" style={{ marginRight: "5px" }} />
|
||||
Add new contribution
|
||||
</Link>
|
||||
</div>
|
||||
{dialogProps.isVisible && (
|
||||
<Dialog
|
||||
dialogContentProps={{
|
||||
title: dialogProps.title
|
||||
}}
|
||||
hidden={!dialogProps.isVisible}
|
||||
onDismiss={this.onDismissDialog}
|
||||
>
|
||||
{Array.isArray(dialogProps.content) ? (
|
||||
<ul>
|
||||
{dialogProps.content.map((message, index) => (
|
||||
<li key={index}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>{String(dialogProps.content)}</p>
|
||||
)}
|
||||
</Dialog>
|
||||
)}
|
||||
{showForm && (
|
||||
<div className="contribution-form-container">
|
||||
<div className="input-container">
|
||||
<div className="contribution-form-fields-container">
|
||||
<TextField
|
||||
className="input-field-container"
|
||||
name="title"
|
||||
label="Title"
|
||||
required={true}
|
||||
placeholder="Enter the title of your sample"
|
||||
value={this.state.title}
|
||||
onChange={this.onTitleChange}
|
||||
/>
|
||||
<TextField
|
||||
className="input-field-container"
|
||||
name="repository"
|
||||
label="URL"
|
||||
required={true}
|
||||
placeholder="Enter the URL of the GitHub repo that hosts your sample "
|
||||
value={this.state.repository}
|
||||
onChange={this.handleInputChange}
|
||||
/>
|
||||
<TextField
|
||||
className="input-field-container"
|
||||
name="description"
|
||||
label="Description"
|
||||
required={true}
|
||||
resizable={false}
|
||||
rows={4}
|
||||
multiline
|
||||
placeholder="Briefly describe your sample (300 characters maximum)"
|
||||
value={this.state.description}
|
||||
onChange={this.onDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="contribution-form-fields-container">
|
||||
<Dropdown
|
||||
className="input-field-container"
|
||||
placeholder="Select which technologies are used by this sample"
|
||||
label="Technologies"
|
||||
onChange={(ev, item) => this.technologiesOptionsChanged(item)}
|
||||
required={true}
|
||||
multiSelect
|
||||
options={technologiesOptions}
|
||||
/>
|
||||
<Dropdown
|
||||
className="input-field-container"
|
||||
placeholder="Select the language used by the Azure Functions involved"
|
||||
label="Language"
|
||||
onChange={(ev, item) => this.languageOptionChanged(item.key)}
|
||||
required={true}
|
||||
options={languageOptions}
|
||||
/>
|
||||
<Dropdown
|
||||
className="input-field-container"
|
||||
placeholder="Select categories for your sample"
|
||||
label="Solution Area"
|
||||
onChange={(ev, item) =>
|
||||
this.solutionAreasOptionsChanged(item)
|
||||
}
|
||||
required={true}
|
||||
multiSelect
|
||||
options={solutionAreasOptions}
|
||||
/>
|
||||
<TextField
|
||||
className="input-field-container"
|
||||
name="template"
|
||||
label="ARM template URL"
|
||||
placeholder="Enter the URL of the ARM template to use to deploy your sample"
|
||||
value={this.state.template}
|
||||
onChange={this.handleInputChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contribution-form-actions-container">
|
||||
<PrimaryButton
|
||||
styles={commonStyles.buttonStyles}
|
||||
text="Add"
|
||||
onClick={this.onAddButtonClick}
|
||||
/>
|
||||
<DefaultButton
|
||||
styles={commonStyles.secondaryButtonStyles}
|
||||
text="Cancel"
|
||||
onClick={this.onCancelButtonClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ContributionForm;
|
|
@ -0,0 +1,30 @@
|
|||
const labelStyles = {
|
||||
root: {
|
||||
fontSize: "13px",
|
||||
lineHeight: "18px",
|
||||
height: "21px",
|
||||
paddingBottom: "1px",
|
||||
paddingTop: 0
|
||||
}
|
||||
};
|
||||
|
||||
export const textFieldStyles = {
|
||||
root: {
|
||||
marginBottom: "10px"
|
||||
},
|
||||
fieldGroup: {
|
||||
height: "23px"
|
||||
},
|
||||
field: {
|
||||
fontSize: "12px",
|
||||
paddingLeft: "7px",
|
||||
selectors: {
|
||||
"::placeholder": {
|
||||
fontSize: "12px"
|
||||
}
|
||||
}
|
||||
},
|
||||
subComponentStyles: {
|
||||
label: labelStyles
|
||||
}
|
||||
};
|
|
@ -0,0 +1,59 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Label } from "office-ui-fabric-react";
|
||||
import ItemList from "../../components/ItemList/ItemList";
|
||||
|
||||
class ContributionsList extends Component {
|
||||
filteredSamples() {
|
||||
let { loggedIn, user, samples } = this.props;
|
||||
let { userName } = user;
|
||||
if (!loggedIn || !userName) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let filter = new RegExp(userName, "i");
|
||||
samples = samples.filter(
|
||||
el =>
|
||||
el.repository.replace("https://github.com/", "").match(filter) || // this match should be against author
|
||||
(el.author && el.author.match(filter))
|
||||
);
|
||||
|
||||
return samples;
|
||||
}
|
||||
|
||||
render() {
|
||||
const headerLabelStyles = {
|
||||
root: {
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
paddingTop: "0px",
|
||||
paddingBottom: "6px"
|
||||
}
|
||||
};
|
||||
const filteredSamples = this.filteredSamples();
|
||||
return (
|
||||
<div className="contribution-list-container">
|
||||
<Label styles={headerLabelStyles}>My samples</Label>
|
||||
{filteredSamples.length > 0 ? (
|
||||
<ItemList filteredSamples={filteredSamples} disableHover={true} />
|
||||
) : (
|
||||
<div className="empty-contribution-list-container">
|
||||
<i>You currently do not have any samples</i>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
return {
|
||||
samples: state.samples,
|
||||
loggedIn: state.authentication.loggedIn,
|
||||
user: state.authentication.user
|
||||
};
|
||||
}
|
||||
|
||||
const ContributionsListContainer = connect(mapStateToProps)(ContributionsList);
|
||||
|
||||
export default ContributionsListContainer;
|
|
@ -0,0 +1,81 @@
|
|||
import React, { Component } from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { connect } from "react-redux";
|
||||
import { Dialog, DialogFooter, DefaultButton } from "office-ui-fabric-react";
|
||||
|
||||
import SignInButton from "../shared/SignInButton";
|
||||
|
||||
class SignInDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleHomeButtonClick = this.handleHomeButtonClick.bind(this);
|
||||
}
|
||||
|
||||
handleHomeButtonClick() {
|
||||
this.props.history.push("/");
|
||||
}
|
||||
|
||||
render() {
|
||||
const footerStyles = {
|
||||
actionsRight: {
|
||||
textAlign: "center",
|
||||
marginRight: "0px"
|
||||
}
|
||||
};
|
||||
const buttonStyles = {
|
||||
root: {
|
||||
fontSize: "12px",
|
||||
height: "32px",
|
||||
minWidth: "40px",
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #0078D7",
|
||||
color: "#0058AD"
|
||||
},
|
||||
rootHovered: {
|
||||
border: "1px solid #0078D7",
|
||||
color: "#0058AD"
|
||||
},
|
||||
label: {
|
||||
fontWeight: "normal"
|
||||
}
|
||||
};
|
||||
|
||||
const { loading, loggedIn } = this.props;
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
hidden={loggedIn}
|
||||
dialogContentProps={{
|
||||
title: "Please sign in",
|
||||
subText: "Please sign in to continue."
|
||||
}}
|
||||
modalProps={{
|
||||
isBlocking: true
|
||||
}}
|
||||
>
|
||||
<DialogFooter styles={footerStyles}>
|
||||
<SignInButton />
|
||||
<DefaultButton
|
||||
styles={buttonStyles}
|
||||
text="Home"
|
||||
onClick={this.handleHomeButtonClick}
|
||||
/>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
loading: state.authentication.loading,
|
||||
loggedIn: state.authentication.loggedIn
|
||||
});
|
||||
|
||||
const SignInDialogContainer = connect(mapStateToProps)(SignInDialog);
|
||||
|
||||
export default withRouter(SignInDialogContainer);
|
|
@ -0,0 +1,110 @@
|
|||
import React, { Component } from "react";
|
||||
import { Icon, Link as FabricLink } from "office-ui-fabric-react";
|
||||
import { trackEvent } from "../../helpers";
|
||||
import { libraryService } from "../../services";
|
||||
import "./ActionBar.scss";
|
||||
|
||||
class ActionBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.outboundRepoClick = this.outboundRepoClick.bind(this);
|
||||
this.outboundDeployClick = this.outboundDeployClick.bind(this);
|
||||
this.openInVSCodeClick = this.openInVSCodeClick.bind(this);
|
||||
this.trackUserActionEvent = this.trackUserActionEvent.bind(this);
|
||||
}
|
||||
|
||||
outboundDeployClick() {
|
||||
this.updateDownloadCount(this.props.id);
|
||||
this.trackUserActionEvent("/sample/deploy/agree");
|
||||
}
|
||||
|
||||
updateDownloadCount(id) {
|
||||
libraryService
|
||||
.updateDownloadCount(id)
|
||||
.then(() => {
|
||||
// do nothing
|
||||
})
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
getDeployLink(template) {
|
||||
return (
|
||||
"https://portal.azure.com/#create/Microsoft.Template/uri/" +
|
||||
encodeURIComponent(template)
|
||||
);
|
||||
}
|
||||
|
||||
outboundRepoClick() {
|
||||
this.trackUserActionEvent("/sample/source");
|
||||
}
|
||||
|
||||
getOpenInVSCodeLink(repository) {
|
||||
return "vscode://vscode.git/clone?url=" + encodeURIComponent(repository);
|
||||
}
|
||||
|
||||
openInVSCodeClick() {
|
||||
this.updateDownloadCount(this.props.id);
|
||||
this.trackUserActionEvent("/sample/openinvscode");
|
||||
}
|
||||
|
||||
trackUserActionEvent(eventName) {
|
||||
let eventData = {
|
||||
id: this.props.id,
|
||||
repository: this.props.repository,
|
||||
template: this.props.template
|
||||
};
|
||||
|
||||
trackEvent(eventName, eventData);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { repository, template } = this.props;
|
||||
|
||||
return (
|
||||
<div className="action-container">
|
||||
<div className="action-item">
|
||||
<FabricLink
|
||||
href={this.getOpenInVSCodeLink(repository)}
|
||||
disabled={!repository}
|
||||
onClick={this.openInVSCodeClick}
|
||||
>
|
||||
<div className="action-link-wrapper">
|
||||
<Icon iconName="Edit" className="fabric-icon-link" />
|
||||
<span className="action-link-text">Edit in VS Code</span>
|
||||
</div>
|
||||
</FabricLink>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<FabricLink
|
||||
href={this.getDeployLink(template)}
|
||||
disabled={!template}
|
||||
target="_blank"
|
||||
onClick={this.outboundDeployClick}
|
||||
>
|
||||
<div className="action-link-wrapper">
|
||||
<Icon iconName="Deploy" className="fabric-icon-link" />
|
||||
<span className="action-link-text">Deploy</span>
|
||||
</div>
|
||||
</FabricLink>
|
||||
</div>
|
||||
<div className="action-item">
|
||||
<FabricLink
|
||||
href={repository}
|
||||
disabled={!repository}
|
||||
target="_blank"
|
||||
onClick={this.outboundRepoClick}
|
||||
>
|
||||
<div className="action-link-wrapper">
|
||||
<Icon iconName="GitHub-12px" className="githubicon" />
|
||||
<span className="action-link-text">Open in Github</span>
|
||||
</div>
|
||||
</FabricLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ActionBar;
|
|
@ -0,0 +1,33 @@
|
|||
.action-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
padding-top: 7px;
|
||||
padding-left: 23px;
|
||||
}
|
||||
|
||||
.action-item {
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.action-link-wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.fabric-icon-link {
|
||||
font-size: 12px;
|
||||
width: 13px;
|
||||
height: 11px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.githubicon {
|
||||
@extend .fabric-icon-link;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.action-link-text {
|
||||
padding-left: 8px;
|
||||
font-size: 12px;
|
||||
margin: auto;
|
||||
}
|
|
@ -0,0 +1,151 @@
|
|||
import React, { Component } from "react";
|
||||
import ReactMarkdown from "markdown-to-jsx";
|
||||
import {
|
||||
Pivot,
|
||||
PivotItem,
|
||||
PivotLinkSize,
|
||||
ScrollablePane
|
||||
} from "office-ui-fabric-react/lib/index";
|
||||
import { githubService } from "../../services";
|
||||
import "./DetailPageContent.scss";
|
||||
|
||||
const defaultLicenseText =
|
||||
"Each application is licensed to you by its owner (which may or may not be Microsoft) under the agreement which accompanies the application. Microsoft is not responsible for any non-Microsoft code and does not screen for security, compatibility, or performance. The applications are not supported by any Microsoft support program or service. The applications are provided AS IS without warranty of any kind.";
|
||||
|
||||
class DetailPageContent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
armTemplateText: "",
|
||||
markdownText: "",
|
||||
licenseText: "",
|
||||
selectedKey: "overview"
|
||||
};
|
||||
|
||||
this.handleLinkClick = this.handleLinkClick.bind(this);
|
||||
}
|
||||
|
||||
// This method is used to fetch readme content from repo when valid repository url is received as props
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (
|
||||
this.props.repository !== prevProps.repository &&
|
||||
prevState.markdownText === ""
|
||||
) {
|
||||
let { repository } = this.props;
|
||||
githubService
|
||||
.getReadMe(repository)
|
||||
.then(data => {
|
||||
var r = new RegExp(
|
||||
"https?://Azuredeploy.net/deploybutton.(png|svg)",
|
||||
"ig"
|
||||
);
|
||||
data = data.replace(r, "");
|
||||
this.setState({ markdownText: data });
|
||||
})
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleLinkClick(pivotItem, ev) {
|
||||
const selectedKey = pivotItem.props.itemKey;
|
||||
this.setState({ selectedKey });
|
||||
if (selectedKey === "armtemplate" && this.state.armTemplateText === "") {
|
||||
const { template } = this.props;
|
||||
if (template) {
|
||||
githubService
|
||||
.getArmTemplate(template)
|
||||
.then(data =>
|
||||
this.setState({
|
||||
armTemplateText: data
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
}
|
||||
if (selectedKey === "license" && this.state.licenseText === "") {
|
||||
const { license, repository } = this.props;
|
||||
githubService
|
||||
.getLicense(license, repository)
|
||||
.then(data =>
|
||||
this.setState({
|
||||
licenseText: data
|
||||
})
|
||||
)
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const pivotStyles = {
|
||||
root: {
|
||||
paddingLeft: "15px"
|
||||
},
|
||||
text: {
|
||||
color: " #0058AD",
|
||||
fontSize: "14px"
|
||||
}
|
||||
};
|
||||
|
||||
const {
|
||||
selectedKey,
|
||||
markdownText,
|
||||
licenseText,
|
||||
armTemplateText
|
||||
} = this.state;
|
||||
return (
|
||||
<div className="detail-page-content">
|
||||
<Pivot
|
||||
styles={pivotStyles}
|
||||
selectedKey={selectedKey}
|
||||
linkSize={PivotLinkSize.large}
|
||||
onLinkClick={(item, ev) => this.handleLinkClick(item, ev)}
|
||||
>
|
||||
<PivotItem headerText="Overview" itemKey="overview">
|
||||
<div className="pivot-item-container">
|
||||
<div className="scrollablePane-wrapper">
|
||||
<ScrollablePane>
|
||||
<ReactMarkdown>{markdownText}</ReactMarkdown>
|
||||
</ScrollablePane>
|
||||
</div>
|
||||
</div>
|
||||
</PivotItem>
|
||||
<PivotItem headerText="License" itemKey="license">
|
||||
<div className="pivot-item-container">
|
||||
<div className="scrollablePane-wrapper">
|
||||
<ScrollablePane>
|
||||
<div className="license-content">
|
||||
<p>{defaultLicenseText}</p>
|
||||
{licenseText !== "" && (
|
||||
<p style={{ borderTop: "2px outset" }}>{licenseText}</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollablePane>
|
||||
</div>
|
||||
</div>
|
||||
</PivotItem>
|
||||
{this.props.template && (
|
||||
<PivotItem headerText="ARM template" itemKey="armtemplate">
|
||||
<div className="pivot-item-container">
|
||||
<div className="scrollablePane-wrapper">
|
||||
<ScrollablePane>
|
||||
<div className="armtemplate-content">
|
||||
<pre>{armTemplateText}</pre>
|
||||
</div>
|
||||
</ScrollablePane>
|
||||
</div>
|
||||
</div>
|
||||
</PivotItem>
|
||||
)}
|
||||
</Pivot>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DetailPageContent;
|
|
@ -0,0 +1,26 @@
|
|||
.detail-page-content {
|
||||
margin-top: 24px;
|
||||
border-top: 1px solid rgba(105, 130, 155, 0.25);
|
||||
border-bottom: 1px solid rgba(105, 130, 155, 0.25);
|
||||
}
|
||||
.pivot-item-container {
|
||||
padding-left: 23px;
|
||||
background-color: #fbfbfb;
|
||||
}
|
||||
.scrollablePane-wrapper {
|
||||
min-height: 60vh;
|
||||
position: relative;
|
||||
max-height: inherit;
|
||||
}
|
||||
|
||||
.armtemplate-content {
|
||||
overflow-wrap: break-word;
|
||||
white-space: pre-line;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.license-content {
|
||||
max-width: 750px;
|
||||
text-align: justify;
|
||||
white-space: pre-line;
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
import React, { Component } from "react";
|
||||
import PageHeaderWithBackButton from "../shared/PageHeaderWithBackButton";
|
||||
import MetricBar from "../MetricBar/MetricBar";
|
||||
|
||||
class DetailPageHeader extends Component {
|
||||
render() {
|
||||
let {
|
||||
title,
|
||||
author,
|
||||
id,
|
||||
totaldownloads,
|
||||
createddate,
|
||||
description,
|
||||
likes,
|
||||
dislikes
|
||||
} = this.props;
|
||||
return (
|
||||
<div>
|
||||
<PageHeaderWithBackButton title={title}>
|
||||
<MetricBar
|
||||
likes={likes}
|
||||
dislikes={dislikes}
|
||||
author={author}
|
||||
id={id}
|
||||
downloads={totaldownloads}
|
||||
createddate={createddate}
|
||||
/>
|
||||
<p>{description}</p>
|
||||
</PageHeaderWithBackButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DetailPageHeader;
|
|
@ -0,0 +1,86 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import ActionBar from "./ActionBar";
|
||||
import DetailPageContent from "./DetailPageContent";
|
||||
import DetailPageHeader from "./DetailPageHeader";
|
||||
|
||||
import { sampleActions } from "../../actions/sampleActions";
|
||||
import { trackEvent } from "../../helpers";
|
||||
|
||||
class DetailView extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
sample: {}
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setCurrentItemInState();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
this.setCurrentItemInState();
|
||||
}
|
||||
|
||||
setCurrentItemInState() {
|
||||
if (!this.state.sample.id && this.props.samples.length > 0) {
|
||||
const id = this.props.match.params.id;
|
||||
let currentItem = this.props.samples.filter(s => s.id === id)[0] || {};
|
||||
this.setState({ sample: currentItem });
|
||||
this.trackPageLoadEvent(currentItem);
|
||||
}
|
||||
}
|
||||
|
||||
trackPageLoadEvent(sample) {
|
||||
let eventData = {
|
||||
id: sample.id,
|
||||
repository: sample.repository,
|
||||
template: sample.template
|
||||
};
|
||||
trackEvent("/sample/detailpage", eventData);
|
||||
}
|
||||
|
||||
render() {
|
||||
let likes = this.state.sample.likes ? this.state.sample.likes : 0;
|
||||
let dislikes = this.state.sample.dislikes ? this.state.sample.dislikes : 0;
|
||||
return (
|
||||
<div>
|
||||
<DetailPageHeader
|
||||
title={this.state.sample.title}
|
||||
author={this.state.sample.author}
|
||||
id={this.state.sample.id}
|
||||
totaldownloads={this.state.sample.totaldownloads}
|
||||
createddate={this.state.sample.createddate}
|
||||
description={this.state.sample.description}
|
||||
likes={likes}
|
||||
dislikes={dislikes}
|
||||
/>
|
||||
<ActionBar
|
||||
id={this.state.sample.id}
|
||||
template={this.state.sample.template}
|
||||
repository={this.state.sample.repository}
|
||||
/>
|
||||
<DetailPageContent
|
||||
template={this.state.sample.template}
|
||||
repository={this.state.sample.repository}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
samples: state.samples
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
getSamplesSuccess: sampleActions.getSamplesSuccess
|
||||
};
|
||||
|
||||
const DetailViewContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(DetailView);
|
||||
|
||||
export default DetailViewContainer;
|
|
@ -0,0 +1,119 @@
|
|||
import React, { Component } from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { connect } from "react-redux";
|
||||
import { ActionButton, ContextualMenuItemType } from "office-ui-fabric-react";
|
||||
|
||||
import { userService } from "../../services";
|
||||
import { userActions } from "../../actions/userActions";
|
||||
import SignInButton from "../shared/SignInButton";
|
||||
import UserPersona from "./UserPersona";
|
||||
|
||||
class AuthControl extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this._renderMenuList = this._renderMenuList.bind(this);
|
||||
this._onSignoutClick = this._onSignoutClick.bind(this);
|
||||
this._onContributionsClick = this._onContributionsClick.bind(this);
|
||||
}
|
||||
|
||||
getMenuItems() {
|
||||
const { user } = this.props;
|
||||
const userName =
|
||||
user.fullName && user.fullName !== "" ? user.fullName : user.displayName;
|
||||
|
||||
return [
|
||||
{
|
||||
key: "Header",
|
||||
itemType: ContextualMenuItemType.Header,
|
||||
text: "Logged in as: " + userName
|
||||
},
|
||||
{
|
||||
key: "divider_1",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
},
|
||||
{
|
||||
key: "contributions",
|
||||
text: "My contributions",
|
||||
onClick: this._onContributionsClick
|
||||
},
|
||||
{
|
||||
key: "divider_1",
|
||||
itemType: ContextualMenuItemType.Divider
|
||||
},
|
||||
{
|
||||
key: "signOut",
|
||||
text: "Sign out",
|
||||
onClick: this._onSignoutClick
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
_renderMenuIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
_renderMenuList(menuListProps, defaultRender) {
|
||||
const { loggedIn } = this.props;
|
||||
if (loggedIn) {
|
||||
return <div>{defaultRender(menuListProps)}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="signin-button-container">
|
||||
<SignInButton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
_onContributionsClick() {
|
||||
this.props.history.push("/contribute");
|
||||
}
|
||||
|
||||
_onSignoutClick() {
|
||||
this.props.logout(); // clear the redux store before making a call to the backend
|
||||
userService
|
||||
.logout()
|
||||
.then(() => {
|
||||
// do nothing
|
||||
})
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<span>
|
||||
<div className="auth-control">
|
||||
<ActionButton
|
||||
onRenderMenuIcon={this._renderMenuIcon}
|
||||
menuProps={{
|
||||
onRenderMenuList: this._renderMenuList,
|
||||
shouldFocusOnMount: true,
|
||||
items: this.getMenuItems()
|
||||
}}
|
||||
>
|
||||
<UserPersona />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
loggedIn: state.authentication.loggedIn,
|
||||
user: state.authentication.user
|
||||
});
|
||||
|
||||
const mapDispatchToProps = {
|
||||
logout: userActions.logout
|
||||
};
|
||||
|
||||
const AuthControlContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(AuthControl);
|
||||
|
||||
export default withRouter(AuthControlContainer);
|
|
@ -0,0 +1,49 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Link } from "office-ui-fabric-react";
|
||||
import { getTheme } from "office-ui-fabric-react";
|
||||
|
||||
import "./Header.scss";
|
||||
import AuthControl from "./AuthControl";
|
||||
|
||||
class Header extends Component {
|
||||
render() {
|
||||
const theme = getTheme();
|
||||
const linkStyles = {
|
||||
root: {
|
||||
marginLeft: "15px",
|
||||
lineHeight: "40px",
|
||||
fontSize: "14px",
|
||||
color: theme.palette.white,
|
||||
selectors: {
|
||||
"&:active, &:hover, &:active:hover, &:visited": {
|
||||
color: theme.palette.white
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="headerbar">
|
||||
<span>
|
||||
<Link
|
||||
styles={linkStyles}
|
||||
href="https://azure.microsoft.com/"
|
||||
target="_blank"
|
||||
>
|
||||
Microsoft Azure
|
||||
</Link>
|
||||
<AuthControl />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
loggedIn: state.authentication.loggedIn
|
||||
});
|
||||
|
||||
const HeaderContainer = connect(mapStateToProps)(Header);
|
||||
|
||||
export default HeaderContainer;
|
|
@ -0,0 +1,14 @@
|
|||
.headerbar {
|
||||
background-color: black;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.auth-control {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.signin-button-container {
|
||||
text-align: center;
|
||||
padding: 20px 16px;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { Persona, PersonaSize, Icon } from "office-ui-fabric-react";
|
||||
import { getTheme } from "office-ui-fabric-react";
|
||||
|
||||
class UserPersona extends Component {
|
||||
_onRenderInitials() {
|
||||
return <Icon iconName="Contact" />;
|
||||
}
|
||||
|
||||
render() {
|
||||
const theme = getTheme();
|
||||
const personaStyles = {
|
||||
root: {
|
||||
height: "40px",
|
||||
color: theme.palette.white,
|
||||
float: "right",
|
||||
selectors: {
|
||||
":hover": {
|
||||
selectors: {
|
||||
$primaryText: {
|
||||
color: theme.palette.white
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
details: {
|
||||
width: "85px"
|
||||
},
|
||||
primaryText: {
|
||||
color: theme.palette.white
|
||||
}
|
||||
};
|
||||
|
||||
const { loading, loggedIn, user } = this.props;
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return loggedIn ? (
|
||||
<Persona
|
||||
styles={personaStyles}
|
||||
text={user.displayName}
|
||||
imageUrl={user.avatarUrl}
|
||||
imageAlt={
|
||||
user.fullName && user.fullName !== ""
|
||||
? user.fullName
|
||||
: user.displayName
|
||||
}
|
||||
size={PersonaSize.size28}
|
||||
/>
|
||||
) : (
|
||||
<Persona
|
||||
styles={personaStyles}
|
||||
text="Guest"
|
||||
imageAlt="Guest"
|
||||
size={PersonaSize.size28}
|
||||
onRenderInitials={this._onRenderInitials}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
loading: state.authentication.loading,
|
||||
loggedIn: state.authentication.loggedIn,
|
||||
user: state.authentication.user
|
||||
});
|
||||
|
||||
const UserPersonaContainer = connect(mapStateToProps)(UserPersona);
|
||||
|
||||
export default UserPersonaContainer;
|
|
@ -0,0 +1,76 @@
|
|||
import React, { Component } from "react";
|
||||
import {
|
||||
FocusZone,
|
||||
FocusZoneDirection,
|
||||
List,
|
||||
Link as FabricLink
|
||||
} from "office-ui-fabric-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import ItemTags from "../ItemTags/ItemTags";
|
||||
import MetricBar from "../MetricBar/MetricBar";
|
||||
|
||||
import "./ItemList.scss";
|
||||
|
||||
class ItemList extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this._onRenderCell = this._onRenderCell.bind(this);
|
||||
this.disableHover = this.props.disableHover || false;
|
||||
}
|
||||
|
||||
_onRenderCell(item, index) {
|
||||
let likes = item.likes ? item.likes : 0;
|
||||
let dislikes = item.dislikes ? item.dislikes : 0;
|
||||
return (
|
||||
<article>
|
||||
<div className={this.disableHover ? "" : "libraryitemContainer"}>
|
||||
<div
|
||||
className={
|
||||
this.disableHover ? "libraryitem" : "libraryitem-withhover"
|
||||
}
|
||||
>
|
||||
<div className="title">
|
||||
<FabricLink
|
||||
as={Link}
|
||||
className="titlelink"
|
||||
to={`/sample/${item.id}`}
|
||||
>
|
||||
{item.title}
|
||||
</FabricLink>
|
||||
</div>
|
||||
<MetricBar
|
||||
likes={likes}
|
||||
dislikes={dislikes}
|
||||
author={item.author}
|
||||
id={item.id}
|
||||
downloads={item.totaldownloads}
|
||||
createddate={item.createddate}
|
||||
/>
|
||||
<div className="description">{item.description}</div>
|
||||
<ItemTags
|
||||
technologies={item.technologies}
|
||||
language={item.language}
|
||||
tags={item.tags}
|
||||
solutionareas={item.solutionareas}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<FocusZone direction={FocusZoneDirection.vertical}>
|
||||
<List
|
||||
items={this.props.filteredSamples}
|
||||
onRenderCell={this._onRenderCell}
|
||||
/>
|
||||
</FocusZone>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ItemList;
|
|
@ -0,0 +1,46 @@
|
|||
.libraryitem {
|
||||
border-bottom: 1px solid #d8d8d8;
|
||||
padding-top: 12px;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.libraryitem-withhover {
|
||||
@extend .libraryitem;
|
||||
&:hover {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.libraryitemContainer {
|
||||
padding-left: 16px;
|
||||
&:hover {
|
||||
margin-top: -1px;
|
||||
margin-bottom: 1px;
|
||||
background-color: white;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
line-height: 21px;
|
||||
color: #0072c6;
|
||||
&:visited {
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.titlelink {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 19px;
|
||||
color: #000000;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 10px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
.tagcontainer {
|
||||
font-size: 14px;
|
||||
line-height: normal;
|
||||
vertical-align: sub;
|
||||
}
|
||||
|
||||
.tag {
|
||||
margin-left: 5px;
|
||||
background: #efefef;
|
||||
border-radius: 10px;
|
||||
padding: 4px 10px 4px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.svg {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
vertical-align: baseline;
|
||||
margin-right: 4px;
|
||||
}
|
|
@ -0,0 +1,43 @@
|
|||
import React, { Component } from "react";
|
||||
import * as Constants from "../shared/Constants";
|
||||
import "./ItemTags.css";
|
||||
|
||||
class ItemTags extends Component {
|
||||
render() {
|
||||
// Tags contain technologies, language, solutionareas and custom tags
|
||||
let allTags = [];
|
||||
|
||||
if (this.props.technologies) {
|
||||
allTags.push(...this.props.technologies);
|
||||
}
|
||||
if (
|
||||
this.props.language !== "" &&
|
||||
this.props.language !== Constants.NotApplicableLanguage
|
||||
) {
|
||||
allTags.push(this.props.language);
|
||||
}
|
||||
if (this.props.solutionareas) {
|
||||
let solutionareas = this.props.solutionareas.filter(
|
||||
s => s !== Constants.OtherSolutionArea
|
||||
);
|
||||
allTags.push(...solutionareas);
|
||||
}
|
||||
|
||||
if (this.props.tags) {
|
||||
allTags.push(...this.props.tags);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="tagcontainer">
|
||||
Tags :
|
||||
{allTags.map((value, index) => (
|
||||
<span className="tag" key={index}>
|
||||
{value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default ItemTags;
|
|
@ -0,0 +1,37 @@
|
|||
#mainContainer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
#sidebar {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
max-width: 15%;
|
||||
min-width: 175px;
|
||||
background-color: #f9f9f9;
|
||||
box-shadow: 0px 0px 1.8px rgba(0, 0, 0, 0.12),
|
||||
0px 3.2px 3.6px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
#content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#contentheader{
|
||||
max-width: 830px;
|
||||
padding-left: 40px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
#list {
|
||||
flex:1;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.list-container {
|
||||
max-width: 765px;
|
||||
}
|
|
@ -0,0 +1,171 @@
|
|||
import React, { Component } from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { connect } from "react-redux";
|
||||
import SideBarContainer from "../../components/sidebar/SideBar";
|
||||
import ContentHeaderContainer from "../ContentHeader/ContentHeader";
|
||||
import ItemList from "../../components/ItemList/ItemList";
|
||||
import "./Main.css";
|
||||
import { queryStringToParams } from "../../helpers";
|
||||
|
||||
class Main extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
initialFilters: this.getFiltersFromQueryParams(),
|
||||
showContentHeaderShadow: false
|
||||
};
|
||||
|
||||
this.onListScrollEvent = this.onListScrollEvent.bind(this);
|
||||
}
|
||||
|
||||
filteredSamples() {
|
||||
let samples = this.props.samples;
|
||||
let currentfilters = this.getFiltersFromQueryParams();
|
||||
let filter = new RegExp(currentfilters.filtertext, "i");
|
||||
samples = samples.filter(
|
||||
el =>
|
||||
el.title.match(filter) ||
|
||||
el.description.match(filter) ||
|
||||
el.language.match(filter) ||
|
||||
el.repository.replace("https://github.com/", "").match(filter) ||
|
||||
(el.tags && el.tags.some(x => x.match(filter))) ||
|
||||
(el.technologies && el.technologies.some(x => x.match(filter))) ||
|
||||
(el.solutionareas && el.solutionareas.some(x => x.match(filter))) ||
|
||||
(el.author && el.author.match(filter))
|
||||
);
|
||||
|
||||
if (currentfilters.categories.technologies.length > 0) {
|
||||
samples = samples.filter(s =>
|
||||
s.technologies.some(t =>
|
||||
currentfilters.categories.technologies.includes(t)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (currentfilters.categories.languages.length > 0) {
|
||||
samples = samples.filter(s =>
|
||||
currentfilters.categories.languages.includes(s.language)
|
||||
);
|
||||
}
|
||||
|
||||
if (currentfilters.categories.solutionareas.length > 0) {
|
||||
samples = samples.filter(s =>
|
||||
s.solutionareas.some(a =>
|
||||
currentfilters.categories.solutionareas.includes(a)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return this.Sort(samples, currentfilters.sortby);
|
||||
}
|
||||
|
||||
Sort(list, sortby) {
|
||||
list = list.map(a => a);
|
||||
if (sortby === "totaldownloads") {
|
||||
list = list.sort(function(a, b) {
|
||||
return b.totaldownloads - a.totaldownloads;
|
||||
});
|
||||
} else if (sortby === "createddate") {
|
||||
list.sort(function(a, b) {
|
||||
let dateA = new Date(a.createddate),
|
||||
dateB = new Date(b.createddate);
|
||||
return dateB - dateA;
|
||||
});
|
||||
} else {
|
||||
list = list.sort(function(a, b) {
|
||||
let titleA = a.title.toLowerCase(),
|
||||
titleB = b.title.toLowerCase();
|
||||
if (titleA < titleB)
|
||||
//sort string ascending
|
||||
return -1;
|
||||
if (titleA > titleB) return 1;
|
||||
return 0; //default return value (no sorting)
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
getFiltersFromQueryParams() {
|
||||
var filter = {
|
||||
categories: {
|
||||
technologies: [],
|
||||
languages: [],
|
||||
solutionareas: []
|
||||
},
|
||||
filtertext: "",
|
||||
sortby: "totaldownloads"
|
||||
};
|
||||
|
||||
var params = queryStringToParams(this.props.location.search);
|
||||
if (params.technology && params.technology.length > 0) {
|
||||
filter.categories.technologies = params.technology.split(",");
|
||||
}
|
||||
|
||||
if (params.language && params.language.length > 0) {
|
||||
filter.categories.languages = params.language.split(",");
|
||||
}
|
||||
|
||||
if (params.solutionarea && params.solutionarea.length > 0) {
|
||||
filter.categories.solutionareas = params.solutionarea.split(",");
|
||||
}
|
||||
if (params.filtertext && params.filtertext.length > 0) {
|
||||
filter.filtertext = params.filtertext;
|
||||
}
|
||||
if (params.sortby && params.sortby.length > 0) {
|
||||
filter.sortby = params.sortby;
|
||||
}
|
||||
|
||||
return filter;
|
||||
}
|
||||
|
||||
onListScrollEvent(e) {
|
||||
let element = e.target;
|
||||
if (element.scrollTop > 0) {
|
||||
!this.state.showContentHeaderShadow &&
|
||||
this.setState({ showContentHeaderShadow: true });
|
||||
} else {
|
||||
this.state.showContentHeaderShadow &&
|
||||
this.setState({ showContentHeaderShadow: false });
|
||||
}
|
||||
}
|
||||
render() {
|
||||
const contentheaderShadowStyle = {
|
||||
boxShadow: this.state.showContentHeaderShadow
|
||||
? "0 4px 6px -6px #222"
|
||||
: "none"
|
||||
};
|
||||
|
||||
return (
|
||||
<div id="mainContainer">
|
||||
<div id="sidebar">
|
||||
<SideBarContainer
|
||||
initialFilters={this.state.initialFilters.categories}
|
||||
/>
|
||||
</div>
|
||||
<div id="content">
|
||||
<div id="contentheader" style={contentheaderShadowStyle}>
|
||||
<ContentHeaderContainer
|
||||
initialSearchText={this.state.initialFilters.filtertext}
|
||||
initialSortBy={this.state.initialFilters.sortby}
|
||||
samples={this.filteredSamples()}
|
||||
/>
|
||||
</div>
|
||||
<div id="list" onScroll={this.onListScrollEvent}>
|
||||
<div className="list-container">
|
||||
<ItemList filteredSamples={this.filteredSamples()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
samples: state.samples
|
||||
});
|
||||
|
||||
const MainContainer = connect(mapStateToProps)(withRouter(Main));
|
||||
|
||||
export default MainContainer;
|
|
@ -0,0 +1,8 @@
|
|||
.metrics {
|
||||
margin-top: 7px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: #555555;
|
||||
display: flex;
|
||||
}
|
|
@ -0,0 +1,132 @@
|
|||
import React, { Component } from "react";
|
||||
import { IconButton } from "office-ui-fabric-react";
|
||||
import { libraryService } from "../../services";
|
||||
|
||||
import "./MetricBar.css";
|
||||
class MetricBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
sentimentAction: "none"
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(nextProps, prevState) {
|
||||
if (nextProps.id && nextProps.id !== prevState.id) {
|
||||
return { sentimentAction: localStorage.getItem(nextProps.id) };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
handleLikeClick() {
|
||||
// If user already liked, then decrement like count and set the sentiment state to none.
|
||||
// If in past disliked and choose to like the sample, then decrement dislike count and increment like count
|
||||
// If not action taken ealier, just increment like count and set sentiment state to liked.
|
||||
if (this.state.sentimentAction === "liked") {
|
||||
this.updateSentiment("none", -1, 0);
|
||||
} else if (this.state.sentimentAction === "disliked") {
|
||||
this.updateSentiment("liked", 1, -1);
|
||||
} else {
|
||||
this.updateSentiment("liked", 1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
handleDislikeClick() {
|
||||
if (this.state.sentimentAction === "liked") {
|
||||
this.updateSentiment("disliked", -1, 1);
|
||||
} else if (this.state.sentimentAction === "disliked") {
|
||||
this.updateSentiment("none", 0, -1);
|
||||
} else {
|
||||
this.updateSentiment("disliked", 0, 1);
|
||||
}
|
||||
}
|
||||
|
||||
updateSentiment(choice, likeChanges, dislikeChanges) {
|
||||
localStorage.setItem(this.props.id, choice);
|
||||
this.setState({ sentimentAction: choice });
|
||||
|
||||
var sentimentPayload = {
|
||||
Id: this.props.id,
|
||||
LikeChanges: likeChanges,
|
||||
DislikeChanges: dislikeChanges
|
||||
};
|
||||
|
||||
libraryService
|
||||
.updateUserSentimentStats(sentimentPayload)
|
||||
.then(() => {
|
||||
// do nothing
|
||||
})
|
||||
.catch(() => {
|
||||
// do nothing
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
let { author, downloads, createddate, likes, dislikes } = this.props;
|
||||
let createdonDate = new Date(createddate);
|
||||
let createdonLocaleDate = createdonDate.toLocaleDateString();
|
||||
|
||||
let likeIconName = "Like";
|
||||
let likeTitle = "Like";
|
||||
let dislikeIconName = "Dislike";
|
||||
let dislikeTitle = "Dislike";
|
||||
|
||||
if (this.state.sentimentAction === "liked") {
|
||||
likeIconName = "LikeSolid";
|
||||
likeTitle = "Liked";
|
||||
likes = likes + 1;
|
||||
}
|
||||
|
||||
if (this.state.sentimentAction === "disliked") {
|
||||
dislikeIconName = "DislikeSolid";
|
||||
dislikeTitle = "Disliked";
|
||||
dislikes = dislikes + 1;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
button: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
padding: 0,
|
||||
marginLeft: 7,
|
||||
marginRight: 7
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="metrics">
|
||||
<div>
|
||||
<span>
|
||||
By: {author} | {downloads}{" "}
|
||||
{downloads === 1 ? "download" : "downloads"} | Created on:{" "}
|
||||
{createdonLocaleDate} |
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
style={styles.button}
|
||||
iconStyle={styles.icon}
|
||||
tooltipStyles={styles.tooltip}
|
||||
iconProps={{ iconName: likeIconName }}
|
||||
title={likeTitle}
|
||||
ariaLabel={likeTitle}
|
||||
onClick={() => this.handleLikeClick()}
|
||||
/>
|
||||
<div>{likes}</div>
|
||||
|
||||
<IconButton
|
||||
style={styles.button}
|
||||
iconStyle={styles.icon}
|
||||
tooltipStyles={styles.tooltip}
|
||||
iconProps={{ iconName: dislikeIconName }}
|
||||
title={dislikeTitle}
|
||||
ariaLabel={dislikeTitle}
|
||||
onClick={() => this.handleDislikeClick()}
|
||||
/>
|
||||
<div>{dislikes}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MetricBar;
|
|
@ -0,0 +1,33 @@
|
|||
import { mergeStyleSets } from "office-ui-fabric-react";
|
||||
|
||||
export const buttonStyles = {
|
||||
root: {
|
||||
fontSize: "12px",
|
||||
height: "25px",
|
||||
marginRight: "8px",
|
||||
minWidth: "0px",
|
||||
paddingRight: "10px",
|
||||
paddingLeft: "10px"
|
||||
},
|
||||
label: {
|
||||
fontWeight: "normal"
|
||||
}
|
||||
};
|
||||
|
||||
const secondaryButtonAdditionalStyles = {
|
||||
root: {
|
||||
backgroundColor: "white",
|
||||
border: "1px solid #0078D7",
|
||||
color: "#0058AD"
|
||||
},
|
||||
rootHovered: {
|
||||
// backgroundColor: "white",
|
||||
border: "1px solid #0078D7",
|
||||
color: "#0058AD"
|
||||
}
|
||||
};
|
||||
|
||||
export const secondaryButtonStyles = mergeStyleSets(
|
||||
buttonStyles,
|
||||
secondaryButtonAdditionalStyles
|
||||
);
|
|
@ -0,0 +1,43 @@
|
|||
export const technologies = [
|
||||
"Functions 2.x",
|
||||
"Functions 1.x",
|
||||
"Logic Apps",
|
||||
"Blob Storage",
|
||||
"Storage Queue",
|
||||
"Cosmos DB",
|
||||
"Cognitive Services",
|
||||
"Azure Active Directory",
|
||||
"App Service",
|
||||
"Key Vault",
|
||||
"SQL Server",
|
||||
"Service Bus Queue",
|
||||
"Event Grid"
|
||||
];
|
||||
|
||||
export const solutionAreas = [
|
||||
"Web API",
|
||||
"Data Processing",
|
||||
"Integration",
|
||||
"Authentication",
|
||||
"Automation",
|
||||
"Event Processing",
|
||||
"Machine Learning",
|
||||
"Scheduled Jobs",
|
||||
"Static Website",
|
||||
"Gaming",
|
||||
"IoT"
|
||||
];
|
||||
|
||||
export const languages = [
|
||||
"JavaScript",
|
||||
"TypeScript",
|
||||
"Java",
|
||||
"C#",
|
||||
"C# Script",
|
||||
"F#",
|
||||
"Python",
|
||||
"PowerShell"
|
||||
];
|
||||
|
||||
export const NotApplicableLanguage = "na";
|
||||
export const OtherSolutionArea = "Other";
|
|
@ -0,0 +1,50 @@
|
|||
import React, { Component } from "react";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { IconButton } from "office-ui-fabric-react";
|
||||
|
||||
import "./PageHeaderWithBackButton.scss";
|
||||
|
||||
class PageHeaderWithBackButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.handleHomeButtonClick = this.handleHomeButtonClick.bind(this);
|
||||
}
|
||||
|
||||
handleHomeButtonClick() {
|
||||
this.props.history.push("/");
|
||||
}
|
||||
|
||||
render() {
|
||||
let { title } = this.props;
|
||||
|
||||
const homeButton = {
|
||||
button: {
|
||||
width: 17,
|
||||
height: 18,
|
||||
marginRight: 12
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-header">
|
||||
<div className="page-title-container">
|
||||
<div className="back-button-icon-container">
|
||||
<IconButton
|
||||
iconProps={{ iconName: "Home" }}
|
||||
title="Home"
|
||||
ariaLabel="Home"
|
||||
style={homeButton.button}
|
||||
onClick={() => this.handleHomeButtonClick()}
|
||||
/>
|
||||
</div>
|
||||
<div className="page-title">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-description-container">{this.props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(PageHeaderWithBackButton);
|
|
@ -0,0 +1,29 @@
|
|||
.page-header {
|
||||
padding-top: 20px;
|
||||
// margin-bottom: 23px; // this cannot be set at the common header control because of pivot control sizes
|
||||
background: #ffffff;
|
||||
padding-left: 23px;
|
||||
}
|
||||
.page-title-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
margin-bottom: 11px;
|
||||
}
|
||||
|
||||
.back-button-icon-container {
|
||||
margin: auto 0;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: black;
|
||||
margin-bottom: auto;
|
||||
}
|
||||
|
||||
.page-description-container {
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
color: #161616;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React, { Component } from "react";
|
||||
import { PrimaryButton } from "office-ui-fabric-react";
|
||||
|
||||
class SignInButton extends Component {
|
||||
handleButtonClick() {
|
||||
const currentLocation = encodeURIComponent(window.location);
|
||||
window.location = `/api/user/login?returnUrl=${currentLocation}`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const buttonStyles = {
|
||||
root: {
|
||||
fontSize: "12px",
|
||||
height: "32px"
|
||||
},
|
||||
label: {
|
||||
fontWeight: "normal"
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PrimaryButton
|
||||
styles={buttonStyles}
|
||||
primary={true}
|
||||
iconProps={{ iconName: "GitHub-16px" }}
|
||||
onClick={this.handleButtonClick}
|
||||
>
|
||||
Sign in with GitHub
|
||||
</PrimaryButton>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default SignInButton;
|
|
@ -0,0 +1,23 @@
|
|||
.sidebar-wrapper{
|
||||
padding-left: 24px;
|
||||
padding-top: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
.filterby-title {
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.Filter-list-header{
|
||||
font-size: 14px;
|
||||
line-height: 16px;
|
||||
color: #555555;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.filterset{
|
||||
margin-bottom: 23px;
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import { Checkbox } from "office-ui-fabric-react/lib/index";
|
||||
import {
|
||||
paramsToQueryString,
|
||||
queryStringToParams,
|
||||
trackEvent
|
||||
} from "../../helpers";
|
||||
|
||||
import * as Constants from "../shared/Constants";
|
||||
import "./SideBar.css";
|
||||
|
||||
class SideBar extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
filters: this.props.initialFilters
|
||||
};
|
||||
}
|
||||
|
||||
isChecked(category, item) {
|
||||
return this.state.filters[category].includes(item);
|
||||
}
|
||||
|
||||
checkboxclicked(ev, checked, category, item) {
|
||||
var currentFilters = this.state.filters;
|
||||
var categoryArray = currentFilters[category];
|
||||
if (!checked) {
|
||||
categoryArray = categoryArray.filter(i => i !== item);
|
||||
} else {
|
||||
categoryArray.push(item);
|
||||
}
|
||||
|
||||
currentFilters[category] = categoryArray;
|
||||
this.setState({ filters: currentFilters }, () => this.ChangeUrl());
|
||||
trackEvent(`/filter/change/${category}`, currentFilters);
|
||||
}
|
||||
|
||||
ChangeUrl() {
|
||||
var params = queryStringToParams(this.props.location.search);
|
||||
delete params["technology"];
|
||||
delete params["language"];
|
||||
delete params["solutionarea"];
|
||||
if (this.state.filters.technologies.length > 0) {
|
||||
params["technology"] = this.state.filters.technologies.join();
|
||||
}
|
||||
if (this.state.filters.languages.length > 0) {
|
||||
params["language"] = this.state.filters.languages.join();
|
||||
}
|
||||
if (this.state.filters.solutionareas.length > 0) {
|
||||
params["solutionarea"] = this.state.filters.solutionareas.join();
|
||||
}
|
||||
|
||||
this.props.history.push(paramsToQueryString(params));
|
||||
}
|
||||
|
||||
render() {
|
||||
const checkboxStyles = index => {
|
||||
return {
|
||||
root: {
|
||||
marginTop: index === 0 ? "9px" : "0px",
|
||||
marginBottom: "5px"
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar-wrapper">
|
||||
<div className="filterby-title">Filter by</div>
|
||||
<div className="filterset">
|
||||
<span className="Filter-list-header">Technology</span>
|
||||
{Constants.technologies.map((technology, index) => (
|
||||
<Checkbox
|
||||
styles={checkboxStyles(index)}
|
||||
label={technology}
|
||||
key={technology}
|
||||
defaultChecked={this.isChecked("technologies", technology)}
|
||||
onChange={(ev, checked) =>
|
||||
this.checkboxclicked(ev, checked, "technologies", technology)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filterset">
|
||||
<span className="Filter-list-header">Language</span>
|
||||
{Constants.languages.map((language, index) => (
|
||||
<Checkbox
|
||||
styles={checkboxStyles(index)}
|
||||
label={language}
|
||||
key={language}
|
||||
defaultChecked={this.isChecked("languages", language)}
|
||||
onChange={(ev, checked) =>
|
||||
this.checkboxclicked(ev, checked, "languages", language)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="filterset">
|
||||
<span className="Filter-list-header">Solution Area</span>
|
||||
{Constants.solutionAreas.map((solutionarea, index) => (
|
||||
<Checkbox
|
||||
styles={checkboxStyles(index)}
|
||||
label={solutionarea}
|
||||
key={solutionarea}
|
||||
defaultChecked={this.isChecked("solutionareas", solutionarea)}
|
||||
onChange={(ev, checked) =>
|
||||
this.checkboxclicked(ev, checked, "solutionareas", solutionarea)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({});
|
||||
|
||||
const mapDispatchToProps = {};
|
||||
|
||||
const SideBarContainer = connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SideBar);
|
||||
|
||||
export default withRouter(SideBarContainer);
|
|
@ -0,0 +1,20 @@
|
|||
export function trackEvent(eventName, eventData) {
|
||||
let appInsights = window.appInsights;
|
||||
if (typeof appInsights !== "undefined") {
|
||||
appInsights.trackEvent(eventName, eventData);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackError(errorString, properties) {
|
||||
let appInsights = window.appInsights;
|
||||
if (typeof appInsights !== "undefined") {
|
||||
appInsights.trackTrace(errorString, properties, 3);
|
||||
}
|
||||
}
|
||||
|
||||
export function trackException(exception, properties) {
|
||||
let appInsights = window.appInsights;
|
||||
if (typeof appInsights !== "undefined") {
|
||||
appInsights.trackException(exception, null, properties);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { trackError, trackException } from "./appinsights";
|
||||
|
||||
export function handleResponse(response) {
|
||||
try {
|
||||
return response.text().then(text => {
|
||||
if (response.ok) {
|
||||
return text;
|
||||
}
|
||||
const error = {
|
||||
status: response.status,
|
||||
error: text || response.statusText
|
||||
};
|
||||
trackError(error.error, { ...error, url: response.url });
|
||||
return Promise.reject(error);
|
||||
});
|
||||
} catch (ex) {
|
||||
trackException(ex, { url: response.url, method: "handleResponse" });
|
||||
return Promise.reject({
|
||||
status: -1,
|
||||
error: "Encountered unexpected exception."
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function handleJsonResponse(response) {
|
||||
return handleResponse(response).then(data => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (ex) {
|
||||
trackException(ex, { url: response.url, method: "handleJsonResponse" });
|
||||
return Promise.reject({
|
||||
status: -1,
|
||||
error: "Encountered unexpected exception."
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./handle-response";
|
||||
export * from "./query-param";
|
||||
export * from "./appinsights";
|
|
@ -0,0 +1,23 @@
|
|||
export function queryStringToParams(queryString) {
|
||||
if (queryString.indexOf("?") > -1) {
|
||||
queryString = queryString.split("?")[1];
|
||||
}
|
||||
var pairs = queryString.split("&");
|
||||
var result = {};
|
||||
pairs.forEach(function(pair) {
|
||||
pair = pair.split("=");
|
||||
if (pair[0] && pair[0].length > 0) {
|
||||
result[pair[0]] = decodeURIComponent(pair[1] || "");
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function paramsToQueryString(params) {
|
||||
var queryString = Object.keys(params)
|
||||
.map(key => {
|
||||
return encodeURIComponent(key) + "=" + encodeURIComponent(params[key]);
|
||||
})
|
||||
.join("&");
|
||||
return "?" + queryString;
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
import { registerIcons } from "office-ui-fabric-react";
|
||||
import "./registerIcons.scss";
|
||||
import { ReactComponent as GithubIconSvg } from "../assets/github.svg";
|
||||
import { ReactComponent as ContributionSvg } from "../assets/contribution.svg";
|
||||
|
||||
export default function registerCustomIcons() {
|
||||
registerIcons({
|
||||
icons: {
|
||||
"GitHub-12px": <GithubIconSvg className="icon-12px" />,
|
||||
"GitHub-16px": <GithubIconSvg className="icon-16px" />,
|
||||
"contribution-svg": <ContributionSvg className="icon-16px" />
|
||||
}
|
||||
});
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
.icon-16px {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
vertical-align: baseline;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.icon-12px {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
vertical-align: baseline;
|
||||
margin-right: 4px;
|
||||
fill: currentColor;
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root,
|
||||
#container {
|
||||
height: 100%;
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// These must be the first lines in src/index.js
|
||||
import "react-app-polyfill/ie11";
|
||||
import "react-app-polyfill/stable";
|
||||
|
||||
import "./index.css";
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { Provider } from "react-redux";
|
||||
import { initializeIcons } from "office-ui-fabric-react";
|
||||
import configureStore from "./reducers";
|
||||
import registerCustomIcons from "./helpers/registerIcons";
|
||||
|
||||
import AppContainer from "./App";
|
||||
|
||||
const store = configureStore();
|
||||
registerCustomIcons();
|
||||
initializeIcons();
|
||||
const rootElement = document.getElementById("root");
|
||||
|
||||
ReactDOM.render(
|
||||
<BrowserRouter>
|
||||
<Provider store={store}>
|
||||
<AppContainer />
|
||||
</Provider>
|
||||
</BrowserRouter>,
|
||||
rootElement
|
||||
);
|
|
@ -0,0 +1,37 @@
|
|||
import { userActionTypes } from "../actions/actionTypes";
|
||||
import initialState from "./initialState";
|
||||
|
||||
export default function authenticationReducer(
|
||||
state = initialState.authentication,
|
||||
action
|
||||
) {
|
||||
switch (action.type) {
|
||||
case userActionTypes.GETCURRENTUSER_REQUEST:
|
||||
return {
|
||||
...state,
|
||||
loading: true
|
||||
};
|
||||
case userActionTypes.GETCURRENTUSER_SUCCESS:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loggedIn: true,
|
||||
user: action.user
|
||||
};
|
||||
case userActionTypes.GETCURRENTUSER_FAILURE:
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
loggedIn: false,
|
||||
user: {}
|
||||
};
|
||||
case userActionTypes.LOGOUT:
|
||||
return {
|
||||
...state,
|
||||
loggedIn: false,
|
||||
user: {}
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
import { combineReducers, createStore } from "redux";
|
||||
|
||||
import samples from "./sampleReducer";
|
||||
import authentication from "./authenticationReducer";
|
||||
|
||||
const rootReducer = combineReducers({
|
||||
samples,
|
||||
authentication
|
||||
});
|
||||
|
||||
export default function configureStore(initialState) {
|
||||
return createStore(rootReducer, initialState);
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export default {
|
||||
samples: [],
|
||||
authentication: {
|
||||
loggedIn: false,
|
||||
user: {},
|
||||
loading: false
|
||||
}
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import { sampleActionTypes } from "../actions/actionTypes";
|
||||
import initialState from "./initialState";
|
||||
|
||||
export default function sampleReducer(state = initialState.samples, action) {
|
||||
switch (action.type) {
|
||||
case sampleActionTypes.GETSAMPLES_SUCCESS:
|
||||
return action.samples;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
import { handleResponse } from "../helpers";
|
||||
|
||||
export const githubService = {
|
||||
getReadMe,
|
||||
getLicense,
|
||||
getArmTemplate
|
||||
};
|
||||
|
||||
function getRawContentUrl(repoUrl, fileName) {
|
||||
let rawUrl = repoUrl
|
||||
.replace("https://github.com", "https://raw.githubusercontent.com")
|
||||
.replace("/tree/", "/");
|
||||
rawUrl = rawUrl.includes("/master/") ? rawUrl + "/" : rawUrl + "/master/";
|
||||
let contentUrl = rawUrl + fileName;
|
||||
return contentUrl;
|
||||
}
|
||||
|
||||
function getReadMe(repoUrl) {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
const readMeUrl = getRawContentUrl(repoUrl, "README.md");
|
||||
return fetch(readMeUrl, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function getLicense(licenseUrl, repoUrl) {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
const contentUrl = licenseUrl || getRawContentUrl(repoUrl, "LICENSE");
|
||||
return fetch(contentUrl, requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function getArmTemplate(templateUrl) {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
return fetch(templateUrl, requestOptions).then(handleResponse);
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
export * from "./user.service";
|
||||
export * from "./library.service";
|
||||
export * from "./github.service";
|
|
@ -0,0 +1,65 @@
|
|||
import { handleResponse, handleJsonResponse } from "../helpers";
|
||||
import { trackException } from "../helpers/appinsights";
|
||||
|
||||
export const libraryService = {
|
||||
getAllSamples,
|
||||
submitNewSample,
|
||||
updateUserSentimentStats,
|
||||
updateDownloadCount
|
||||
};
|
||||
|
||||
function getAllSamples() {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
|
||||
return fetch("/api/Library", requestOptions).then(handleJsonResponse);
|
||||
}
|
||||
|
||||
function submitNewSample(item) {
|
||||
const requestOptions = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(item),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
};
|
||||
return fetch("/api/library", requestOptions)
|
||||
.then(handleJsonResponse)
|
||||
.catch(data => {
|
||||
let error = data.error;
|
||||
if (data.status === 400) {
|
||||
try {
|
||||
error = JSON.parse(data.error);
|
||||
} catch (ex) {
|
||||
trackException(ex, { method: "submitNewSample" });
|
||||
}
|
||||
}
|
||||
return Promise.reject({
|
||||
status: data.status,
|
||||
error: error
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function updateUserSentimentStats(sentimentPayload) {
|
||||
const requestOptions = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(sentimentPayload),
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
};
|
||||
return fetch("/api/metrics/sentiment", requestOptions).then(handleResponse);
|
||||
}
|
||||
|
||||
function updateDownloadCount(id) {
|
||||
const requestOptions = {
|
||||
method: "PUT",
|
||||
body: '"' + id + '"',
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
};
|
||||
return fetch("/api/metrics/downloads", requestOptions).then(handleResponse);
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { handleResponse, handleJsonResponse } from "../helpers";
|
||||
|
||||
export const userService = {
|
||||
getCurrentUser,
|
||||
logout
|
||||
};
|
||||
|
||||
function getCurrentUser() {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
return fetch("/api/user", requestOptions).then(handleJsonResponse);
|
||||
}
|
||||
|
||||
function logout() {
|
||||
const requestOptions = {
|
||||
method: "GET"
|
||||
};
|
||||
return fetch("/api/user/logout", requestOptions).then(handleResponse);
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"ProviderId": "Microsoft.ApplicationInsights.ConnectedService.ConnectedServiceProvider",
|
||||
"Version": "8.11.10402.2",
|
||||
"Version": "8.14.11009.1",
|
||||
"GettingStartedDocument": {
|
||||
"Uri": "https://go.microsoft.com/fwlink/?LinkID=798432"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,142 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Text.RegularExpressions;
|
||||
using ServerlessLibrary.Models;
|
||||
using Newtonsoft.Json;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
|
||||
// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
|
||||
|
||||
namespace ServerlessLibrary.Controllers
|
||||
{
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class LibraryController : Controller
|
||||
{
|
||||
ICacheService _cacheService;
|
||||
ILibraryStore _libraryStore;
|
||||
|
||||
public LibraryController(ICacheService cacheService, ILibraryStore libraryStore)
|
||||
{
|
||||
this._cacheService = cacheService;
|
||||
this._libraryStore = libraryStore;
|
||||
}
|
||||
|
||||
// GET: api/<controller>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<LibraryItemWithStats>), 200)]
|
||||
public JsonResult Get(string filterText, string language)
|
||||
{
|
||||
//TODO: Add filtering for solution areas and technologies.
|
||||
var results = _cacheService.GetCachedItems();
|
||||
var filteredResults = results.Where(
|
||||
x =>
|
||||
(
|
||||
(string.IsNullOrWhiteSpace(language) || x.Language == language) &&
|
||||
(
|
||||
string.IsNullOrWhiteSpace(filterText)
|
||||
|| Regex.IsMatch(x.Title, filterText, RegexOptions.IgnoreCase)
|
||||
|| Regex.IsMatch(x.Description, filterText, RegexOptions.IgnoreCase)
|
||||
|| Regex.IsMatch(x.Repository.Replace("https://github.com/", "", StringComparison.InvariantCulture), filterText, RegexOptions.IgnoreCase)
|
||||
|| (!string.IsNullOrWhiteSpace(x.Author) && Regex.IsMatch(x.Author, filterText, RegexOptions.IgnoreCase))
|
||||
|| (x.Tags != null && x.Tags.Any(t => Regex.IsMatch(t, filterText, RegexOptions.IgnoreCase)))
|
||||
|| (x.Technologies != null && x.Technologies.Any(t => Regex.IsMatch(t, filterText, RegexOptions.IgnoreCase)))
|
||||
|| (x.SolutionAreas != null && x.SolutionAreas.Any(c => Regex.IsMatch(c, filterText, RegexOptions.IgnoreCase)))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return new JsonResult(filteredResults);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(LibraryItem), 200)]
|
||||
public async Task<IActionResult> Put([FromBody]LibraryItem libraryItem)
|
||||
{
|
||||
if (!User.Identity.IsAuthenticated)
|
||||
{
|
||||
return Unauthorized();
|
||||
}
|
||||
|
||||
var validationsErrors = ValidateLibraryItem(libraryItem);
|
||||
if (validationsErrors?.Count > 0)
|
||||
{
|
||||
return BadRequest(validationsErrors);
|
||||
}
|
||||
|
||||
// assign id, created date
|
||||
libraryItem.Id = Guid.NewGuid().ToString();
|
||||
libraryItem.CreatedDate = DateTime.UtcNow;
|
||||
|
||||
// set the author to current authenticated user
|
||||
GitHubUser user = new GitHubUser(User);
|
||||
libraryItem.Author = user.UserName;
|
||||
await StorageHelper.submitContributionForApproval(JsonConvert.SerializeObject(libraryItem));
|
||||
return new JsonResult(libraryItem);
|
||||
}
|
||||
|
||||
private static List<string> ValidateLibraryItem(LibraryItem libraryItem)
|
||||
{
|
||||
List<string> errors = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(libraryItem.Title))
|
||||
{
|
||||
errors.Add("Title cannot be empty");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryItem.Repository) || !IsValidUri(libraryItem.Repository))
|
||||
{
|
||||
errors.Add("Repository URL must be a valid GitHub URL");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryItem.Description))
|
||||
{
|
||||
errors.Add("Description cannot be empty");
|
||||
}
|
||||
|
||||
if (libraryItem.Technologies.Length == 0)
|
||||
{
|
||||
errors.Add("At least one technology must be specified");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryItem.Language))
|
||||
{
|
||||
errors.Add("Language must be specified");
|
||||
}
|
||||
|
||||
if (libraryItem.SolutionAreas.Length == 0)
|
||||
{
|
||||
errors.Add("At least one solution area must be specified");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(libraryItem.Template) && !IsValidUri(libraryItem.Template, "raw.githubusercontent.com"))
|
||||
{
|
||||
errors.Add("ARM template URL must point to the raw path of the ARM template (https://raw.githubusercontent.com/...)");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
private static bool IsValidUri(string uriString, string expectedHostName = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var uri = new Uri(uriString);
|
||||
if (string.IsNullOrWhiteSpace(expectedHostName))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else
|
||||
{
|
||||
return string.Equals(expectedHostName, uri.Host, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,67 @@
|
|||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Newtonsoft.Json;
|
||||
using ServerlessLibrary.Models;
|
||||
using System;
|
||||
|
||||
namespace ServerlessLibrary.Controllers
|
||||
{
|
||||
[Route("api/[controller]/[action]")]
|
||||
[ApiController]
|
||||
public class MetricsController : ControllerBase
|
||||
{
|
||||
private readonly ILogger<MetricsController> logger;
|
||||
|
||||
public MetricsController(ILogger<MetricsController> logger)
|
||||
{
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
// PUT api/<controller>/downloads
|
||||
[ProducesResponseType(typeof(bool), 200)]
|
||||
[HttpPut]
|
||||
public JsonResult Downloads([FromBody]string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
StorageHelper.updateUserStats(JsonConvert.SerializeObject(new { id, userAction = "download" })).Wait();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Unable to update download count");
|
||||
}
|
||||
|
||||
return new JsonResult(true);
|
||||
}
|
||||
|
||||
// PUT api/<controller>/sentiment
|
||||
[ProducesResponseType(typeof(bool), 200)]
|
||||
[HttpPut]
|
||||
public IActionResult Sentiment([FromBody]SentimentPayload sentimentPayload)
|
||||
{
|
||||
if (sentimentPayload.LikeChanges < -1
|
||||
|| sentimentPayload.LikeChanges > 1
|
||||
|| sentimentPayload.LikeChanges == sentimentPayload.DislikeChanges)
|
||||
{
|
||||
return BadRequest("Invalid values for like or dislike count");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
StorageHelper.updateUserStats(JsonConvert.SerializeObject(new
|
||||
{
|
||||
id = sentimentPayload.Id,
|
||||
userAction = "Sentiment",
|
||||
likeChanges = sentimentPayload.LikeChanges,
|
||||
dislikeChanges = sentimentPayload.DislikeChanges
|
||||
})).Wait();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.logger.LogError(ex, "Unable to update sentiments");
|
||||
}
|
||||
|
||||
return new JsonResult(true);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using ServerlessLibrary.Models;
|
||||
|
||||
namespace ServerlessLibrary.Controllers
|
||||
{
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class UserController : ControllerBase
|
||||
{
|
||||
[HttpGet("login"), HttpPost("login")]
|
||||
public IActionResult Login(string returnUrl = "/")
|
||||
{
|
||||
if (User.Identity.IsAuthenticated)
|
||||
{
|
||||
return new RedirectResult(returnUrl);
|
||||
}
|
||||
|
||||
// Instruct the middleware corresponding to the requested external identity
|
||||
// provider to redirect the user agent to its own authorization endpoint.
|
||||
// Note: the authenticationScheme parameter must match the value configured in Startup.cs.
|
||||
// If no scheme is provided then the DefaultChallengeScheme will be used
|
||||
return Challenge(new AuthenticationProperties { RedirectUri = returnUrl });
|
||||
}
|
||||
|
||||
[HttpGet("logout"), HttpPost("logout")]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
// Instruct the cookies middleware to delete the local cookie which
|
||||
// was created after a successful authentication flow.
|
||||
return SignOut(
|
||||
new AuthenticationProperties { RedirectUri = "/" },
|
||||
CookieAuthenticationDefaults.AuthenticationScheme);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GitHubUser), 200)]
|
||||
public IActionResult Get()
|
||||
{
|
||||
if (User.Identity.IsAuthenticated)
|
||||
{
|
||||
GitHubUser user = new GitHubUser(User);
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
return Unauthorized();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Azure.Documents;
|
||||
using Microsoft.Azure.Documents.Client;
|
||||
using Microsoft.Azure.Documents.Linq;
|
||||
using ServerlessLibrary.Models;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
/// <summary>
|
||||
/// Cosmos db Library store
|
||||
/// </summary>
|
||||
public class CosmosLibraryStore : ILibraryStore
|
||||
{
|
||||
public CosmosLibraryStore()
|
||||
{
|
||||
CosmosDBRepository<LibraryItem>.Initialize();
|
||||
}
|
||||
|
||||
public async Task Add(LibraryItem libraryItem)
|
||||
{
|
||||
await CosmosDBRepository<LibraryItem>.CreateItemAsync(libraryItem);
|
||||
}
|
||||
|
||||
async public Task<IList<LibraryItem>> GetAllItems()
|
||||
{
|
||||
IEnumerable<LibraryItem> libraryItems = await CosmosDBRepository<LibraryItem>.GetAllItemsAsync();
|
||||
return libraryItems.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cosmos db APIs
|
||||
/// </summary>
|
||||
/// <typeparam name="T"></typeparam>
|
||||
static class CosmosDBRepository<T> where T : class
|
||||
{
|
||||
private static readonly string DatabaseId = ServerlessLibrarySettings.Database;
|
||||
private static readonly string CollectionId = ServerlessLibrarySettings.Collection;
|
||||
private static DocumentClient client;
|
||||
|
||||
public static async Task<T> GetItemAsync(string id)
|
||||
{
|
||||
try
|
||||
{
|
||||
Document document = await client.ReadDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id));
|
||||
return (T)(dynamic)document;
|
||||
}
|
||||
catch (DocumentClientException e)
|
||||
{
|
||||
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<T>> GetAllItemsAsync()
|
||||
{
|
||||
IDocumentQuery<T> query = client.CreateDocumentQuery<T>(
|
||||
UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId),
|
||||
new FeedOptions { MaxItemCount = -1, EnableCrossPartitionQuery = true })
|
||||
.AsDocumentQuery();
|
||||
|
||||
List<T> results = new List<T>();
|
||||
while (query.HasMoreResults)
|
||||
{
|
||||
results.AddRange(await query.ExecuteNextAsync<T>());
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
public static async Task<Document> CreateItemAsync(T item)
|
||||
{
|
||||
return await client.CreateDocumentAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId), item);
|
||||
}
|
||||
|
||||
public static async Task<Document> UpdateItemAsync(string id, T item)
|
||||
{
|
||||
return await client.ReplaceDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id), item);
|
||||
}
|
||||
|
||||
public static async Task DeleteItemAsync(string id)
|
||||
{
|
||||
await client.DeleteDocumentAsync(UriFactory.CreateDocumentUri(DatabaseId, CollectionId, id));
|
||||
}
|
||||
|
||||
public static void Initialize()
|
||||
{
|
||||
if (client == null)
|
||||
{
|
||||
client = new DocumentClient(new Uri(ServerlessLibrarySettings.CosmosEndpoint), ServerlessLibrarySettings.CosmosAuthkey);
|
||||
CreateDatabaseIfNotExistsAsync().Wait();
|
||||
CreateCollectionIfNotExistsAsync().Wait();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateDatabaseIfNotExistsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.ReadDatabaseAsync(UriFactory.CreateDatabaseUri(DatabaseId));
|
||||
}
|
||||
catch (DocumentClientException e)
|
||||
{
|
||||
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
await client.CreateDatabaseAsync(new Database { Id = DatabaseId });
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task CreateCollectionIfNotExistsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.ReadDocumentCollectionAsync(UriFactory.CreateDocumentCollectionUri(DatabaseId, CollectionId));
|
||||
}
|
||||
catch (DocumentClientException e)
|
||||
{
|
||||
if (e.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
await client.CreateDocumentCollectionAsync(
|
||||
UriFactory.CreateDatabaseUri(DatabaseId),
|
||||
new DocumentCollection { Id = CollectionId },
|
||||
new RequestOptions { OfferThroughput = 400 });
|
||||
}
|
||||
else
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ServerlessLibrary.Models;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for serverless library store
|
||||
/// </summary>
|
||||
public interface ILibraryStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Add an item to library
|
||||
/// </summary>
|
||||
/// <param name="libraryItem">Library item </param>
|
||||
Task Add(LibraryItem libraryItem);
|
||||
|
||||
/// <summary>
|
||||
/// Get all items from library
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
Task<IList<LibraryItem>> GetAllItems();
|
||||
}
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Newtonsoft.Json;
|
||||
using System.Threading;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
|
||||
// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
[Produces("application/json")]
|
||||
[Route("api/[controller]")]
|
||||
public class LibraryController : Controller
|
||||
{
|
||||
ICacheService _cacheService;
|
||||
public LibraryController(ICacheService cacheService)
|
||||
{
|
||||
this._cacheService = cacheService;
|
||||
}
|
||||
// GET: api/<controller>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IEnumerable<LibraryItem>), 200)]
|
||||
public JsonResult Get(string filterText, string type, string language)
|
||||
{
|
||||
|
||||
var results = _cacheService.GetCachedItems();
|
||||
var filteredResults = results.Where(
|
||||
x =>
|
||||
(
|
||||
(string.IsNullOrWhiteSpace(language) || x.Language == language) &&
|
||||
(string.IsNullOrWhiteSpace(type) || x.Type == type) &&
|
||||
(
|
||||
string.IsNullOrWhiteSpace(filterText)
|
||||
|| Regex.IsMatch(x.Title, filterText, RegexOptions.IgnoreCase)
|
||||
|| Regex.IsMatch(x.Description, filterText, RegexOptions.IgnoreCase)
|
||||
|| Regex.IsMatch(x.AuthorType, filterText, RegexOptions.IgnoreCase)
|
||||
|| Regex.IsMatch(x.Repository.AbsoluteUri.Replace("https://github.com/", "", StringComparison.InvariantCulture), filterText, RegexOptions.IgnoreCase)
|
||||
|| (x.RuntimeVersion != null && Regex.IsMatch(x.RuntimeVersion, filterText, RegexOptions.IgnoreCase))
|
||||
|| (x.Tags != null && x.Tags.Any(t => Regex.IsMatch(t,filterText, RegexOptions.IgnoreCase)))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return new JsonResult(filteredResults);
|
||||
}
|
||||
|
||||
// PUT api/<controller>/
|
||||
[ProducesResponseType(typeof(bool), 200)]
|
||||
[HttpPut()]
|
||||
public JsonResult Put([FromBody]string template)
|
||||
{
|
||||
|
||||
StorageHelper.updateDownloadCount(JsonConvert.SerializeObject( new { template = WebUtility.UrlDecode(template) }));
|
||||
return new JsonResult(true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
public class LibraryItem
|
||||
{
|
||||
[JsonProperty(PropertyName = "title", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "template", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public Uri Template { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "repository", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public Uri Repository { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "description", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tags", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string[] Tags { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "language", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Language { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "type", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Type { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "author", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Author { get; private set; }
|
||||
|
||||
[JsonProperty(PropertyName = "authortype", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string AuthorType { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "authortypedesc", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string AuthorTypeDesc { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "totaldownloads", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int TotalDownloads { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "downloadsthismonth", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int DownloadsThisMonth { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "downloadsthisweek", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int DownloadsThisWeek { get; set; }
|
||||
[JsonProperty(PropertyName = "downloadstoday", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int DownloadsToday { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "runtimeversion", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string RuntimeVersion { get; set; }
|
||||
|
||||
public LibraryItem(string title, string template, string repository, string description, string language,
|
||||
string type)
|
||||
{
|
||||
this.Title = title;
|
||||
this.Template = string.IsNullOrWhiteSpace(template)? null : new Uri(template);
|
||||
this.Repository = new Uri(repository);
|
||||
this.Description = description;
|
||||
this.Language = language;
|
||||
this.Type = type;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
using System.Security.Claims;
|
||||
using static ServerlessLibrary.OAuth.GitHub.GitHubAuthenticationConstants;
|
||||
|
||||
namespace ServerlessLibrary.Models
|
||||
{
|
||||
public class GitHubUser
|
||||
{
|
||||
public GitHubUser()
|
||||
{
|
||||
}
|
||||
|
||||
public GitHubUser(ClaimsPrincipal claimsPrincipal)
|
||||
{
|
||||
this.FullName = claimsPrincipal.FindFirstValue(Claims.Name);
|
||||
this.Email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
|
||||
this.AvatarUrl = claimsPrincipal.FindFirstValue(Claims.Avatar);
|
||||
this.UserName = claimsPrincipal.FindFirstValue(Claims.Login);
|
||||
}
|
||||
|
||||
public string FullName { get; set; }
|
||||
|
||||
public string Email { get; set; }
|
||||
|
||||
public string AvatarUrl { get; set; }
|
||||
|
||||
public string UserName { get; set; }
|
||||
|
||||
public string DisplayName
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(this.FullName))
|
||||
{
|
||||
return this.FullName.Split(' ')?[0];
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(this.UserName))
|
||||
{
|
||||
return this.UserName;
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
using System;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace ServerlessLibrary.Models
|
||||
{
|
||||
public class LibraryItemWithStats : LibraryItem
|
||||
{
|
||||
[JsonProperty(PropertyName = "totaldownloads", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int TotalDownloads { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "likes", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int Likes { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "dislikes", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public int Dislikes { get; set; }
|
||||
}
|
||||
|
||||
public class LibraryItem
|
||||
{
|
||||
[JsonProperty(PropertyName = "id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "createddate")]
|
||||
public DateTime CreatedDate { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "title", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "template", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Template { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "repository", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Repository { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "description", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Description { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "tags", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string[] Tags { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "language", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Language { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "technologies", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string[] Technologies { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "solutionareas", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string[] SolutionAreas { get; set; }
|
||||
|
||||
[JsonProperty(PropertyName = "author", DefaultValueHandling = DefaultValueHandling.Include)]
|
||||
public string Author { get; internal set; }
|
||||
|
||||
internal T ConvertTo<T>()
|
||||
{
|
||||
var serializedObj = JsonConvert.SerializeObject(this);
|
||||
return JsonConvert.DeserializeObject<T>(serializedObj);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
namespace ServerlessLibrary.Models
|
||||
{
|
||||
public class SentimentPayload
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public int LikeChanges { get; set; }
|
||||
public int DislikeChanges { get; set; }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
namespace ServerlessLibrary.OAuth.GitHub
|
||||
{
|
||||
/// <summary>
|
||||
/// Contains constants specific to the <see cref="GitHubAuthenticationHandler"/>.
|
||||
/// </summary>
|
||||
public static class GitHubAuthenticationConstants
|
||||
{
|
||||
public static class Claims
|
||||
{
|
||||
public const string Name = "urn:github:name";
|
||||
public const string Url = "urn:github:url";
|
||||
public const string Login = "urn:github:login";
|
||||
public const string Avatar = "urn:github:avatar";
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
namespace ServerlessLibrary.OAuth.GitHub
|
||||
{
|
||||
/// <summary>
|
||||
/// Default values used by the GitHub authentication middleware.
|
||||
/// </summary>
|
||||
public static class GitHubAuthenticationDefaults
|
||||
{
|
||||
/// <summary>
|
||||
/// Default value for <see cref="AuthenticationScheme.Name"/>.
|
||||
/// </summary>
|
||||
public const string AuthenticationScheme = "GitHub";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="AuthenticationScheme.DisplayName"/>.
|
||||
/// </summary>
|
||||
public const string DisplayName = "GitHub";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="AuthenticationSchemeOptions.ClaimsIssuer"/>.
|
||||
/// </summary>
|
||||
public const string Issuer = "GitHub";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="RemoteAuthenticationOptions.CallbackPath"/>.
|
||||
/// </summary>
|
||||
public const string CallbackPath = "/signin-github";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="OAuthOptions.AuthorizationEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string AuthorizationEndpoint = "https://github.com/login/oauth/authorize";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="OAuthOptions.TokenEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string TokenEndpoint = "https://github.com/login/oauth/access_token";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="OAuthOptions.UserInformationEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string UserInformationEndpoint = "https://api.github.com/user";
|
||||
|
||||
/// <summary>
|
||||
/// Default value for <see cref="GitHubAuthenticationOptions.UserEmailsEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string UserEmailsEndpoint = "https://api.github.com/user/emails";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for <see cref="UserInformationEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string UserInformationScope = "user";
|
||||
|
||||
/// <summary>
|
||||
/// Scope for <see cref="UserEmailsEndpoint"/>.
|
||||
/// </summary>
|
||||
public const string UserEmailsScope = "user:email";
|
||||
}
|
||||
}
|
|
@ -0,0 +1,105 @@
|
|||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Claims;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace ServerlessLibrary.OAuth.GitHub
|
||||
{
|
||||
public class GitHubAuthenticationHandler : OAuthHandler<GitHubAuthenticationOptions>
|
||||
{
|
||||
public GitHubAuthenticationHandler(
|
||||
IOptionsMonitor<GitHubAuthenticationOptions> options,
|
||||
ILoggerFactory logger,
|
||||
UrlEncoder encoder,
|
||||
ISystemClock clock)
|
||||
: base(options, logger, encoder, clock)
|
||||
{
|
||||
}
|
||||
|
||||
protected override async Task<AuthenticationTicket> CreateTicketAsync(ClaimsIdentity identity,
|
||||
AuthenticationProperties properties, OAuthTokenResponse tokens)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Options.UserInformationEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
|
||||
var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Logger.LogError("An error occurred while retrieving the user profile: the remote server " +
|
||||
"returned a {Status} response with the following payload: {Headers} {Body}.",
|
||||
/* Status: */ response.StatusCode,
|
||||
/* Headers: */ response.Headers.ToString(),
|
||||
/* Body: */ await response.Content.ReadAsStringAsync());
|
||||
|
||||
throw new HttpRequestException("An error occurred while retrieving the user profile.");
|
||||
}
|
||||
|
||||
var payload = JObject.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
var principal = new ClaimsPrincipal(identity);
|
||||
var context = new OAuthCreatingTicketContext(principal, properties, Context, Scheme, Options, Backchannel, tokens, payload);
|
||||
|
||||
context.RunClaimActions(payload);
|
||||
|
||||
// When the email address is not public, retrieve it from
|
||||
// the emails endpoint if the user:email scope is specified.
|
||||
if (!string.IsNullOrEmpty(Options.UserEmailsEndpoint) &&
|
||||
!identity.HasClaim(claim => claim.Type == ClaimTypes.Email) && Options.Scope.Contains("user:email"))
|
||||
{
|
||||
var address = await GetEmailAsync(tokens);
|
||||
if (!string.IsNullOrEmpty(address))
|
||||
{
|
||||
identity.AddClaim(new Claim(ClaimTypes.Email, address, ClaimValueTypes.String, Options.ClaimsIssuer));
|
||||
}
|
||||
}
|
||||
|
||||
await Options.Events.CreatingTicket(context);
|
||||
return new AuthenticationTicket(context.Principal, context.Properties, Scheme.Name);
|
||||
}
|
||||
|
||||
protected virtual async Task<string> GetEmailAsync(OAuthTokenResponse tokens)
|
||||
{
|
||||
// See https://developer.github.com/v3/users/emails/ for more information about the /user/emails endpoint.
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, Options.UserEmailsEndpoint);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", tokens.AccessToken);
|
||||
|
||||
// Failed requests shouldn't cause an error: in this case, return null to indicate that the email address cannot be retrieved.
|
||||
var response = await Backchannel.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, Context.RequestAborted);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Logger.LogWarning("An error occurred while retrieving the email address associated with the logged in user: " +
|
||||
"the remote server returned a {Status} response with the following payload: {Headers} {Body}.",
|
||||
/* Status: */ response.StatusCode,
|
||||
/* Headers: */ response.Headers.ToString(),
|
||||
/* Body: */ await response.Content.ReadAsStringAsync());
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = JArray.Parse(await response.Content.ReadAsStringAsync());
|
||||
|
||||
return (from address in payload.AsJEnumerable()
|
||||
where address.Value<bool>("primary")
|
||||
select address.Value<string>("email")).FirstOrDefault();
|
||||
}
|
||||
|
||||
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
|
||||
{
|
||||
return base.HandleAuthenticateAsync();
|
||||
}
|
||||
|
||||
protected override Task<HandleRequestResult> HandleRemoteAuthenticateAsync()
|
||||
{
|
||||
return base.HandleRemoteAuthenticateAsync();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,41 @@
|
|||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using static ServerlessLibrary.OAuth.GitHub.GitHubAuthenticationConstants;
|
||||
|
||||
namespace ServerlessLibrary.OAuth.GitHub
|
||||
{
|
||||
/// <summary>
|
||||
/// Defines a set of options used by <see cref="GitHubAuthenticationHandler"/>.
|
||||
/// </summary>
|
||||
public class GitHubAuthenticationOptions : OAuthOptions
|
||||
{
|
||||
public GitHubAuthenticationOptions()
|
||||
{
|
||||
ClaimsIssuer = GitHubAuthenticationDefaults.Issuer;
|
||||
|
||||
CallbackPath = new PathString(GitHubAuthenticationDefaults.CallbackPath);
|
||||
|
||||
AuthorizationEndpoint = GitHubAuthenticationDefaults.AuthorizationEndpoint;
|
||||
TokenEndpoint = GitHubAuthenticationDefaults.TokenEndpoint;
|
||||
UserInformationEndpoint = GitHubAuthenticationDefaults.UserInformationEndpoint;
|
||||
//Scope.Add(GitHubAuthenticationDefaults.UserInformationScope);
|
||||
Scope.Add(GitHubAuthenticationDefaults.UserEmailsScope);
|
||||
|
||||
ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
|
||||
ClaimActions.MapJsonKey(ClaimTypes.Name, "login");
|
||||
ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
|
||||
ClaimActions.MapJsonKey(Claims.Name, "name");
|
||||
ClaimActions.MapJsonKey(Claims.Url, "html_url");
|
||||
ClaimActions.MapJsonKey(Claims.Login, "login");
|
||||
ClaimActions.MapJsonKey(Claims.Avatar, "avatar_url");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the address of the endpoint exposing
|
||||
/// the email addresses associated with the logged in user.
|
||||
/// </summary>
|
||||
public string UserEmailsEndpoint { get; set; } = GitHubAuthenticationDefaults.UserEmailsEndpoint;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
@page
|
||||
@model ErrorModel
|
||||
@{
|
||||
ViewData["Title"] = "Error";
|
||||
}
|
||||
|
||||
<h1 class="text-danger">Error.</h1>
|
||||
<h2 class="text-danger">An error occurred while processing your request.</h2>
|
||||
|
||||
@if (Model.ShowRequestId)
|
||||
{
|
||||
<p>
|
||||
<strong>Request ID:</strong> <code>@Model.RequestId</code>
|
||||
</p>
|
||||
}
|
||||
|
||||
<h3>Development Mode</h3>
|
||||
<p>
|
||||
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Development environment should not be enabled in deployed applications</strong>, as it can result in sensitive information from exceptions being displayed to end users. For local debugging, development environment can be enabled by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>, and restarting the application.
|
||||
</p>
|
|
@ -0,0 +1,23 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||
|
||||
namespace ServerlessLibraryAPI.Pages
|
||||
{
|
||||
[ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)]
|
||||
public class ErrorModel : PageModel
|
||||
{
|
||||
public string RequestId { get; set; }
|
||||
|
||||
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
|
||||
|
||||
public void OnGet()
|
||||
{
|
||||
RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
@using ServerlessLibraryAPI
|
||||
@namespace ServerlessLibraryAPI.Pages
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
|
@ -1,12 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.ApplicationInsights;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
|
@ -14,14 +9,21 @@ namespace ServerlessLibrary
|
|||
{
|
||||
public static void Main(string[] args)
|
||||
{
|
||||
BuildWebHost(args).Run();
|
||||
CreateWebHostBuilder(args).Build().Run();
|
||||
}
|
||||
|
||||
public static IWebHost BuildWebHost(string[] args) =>
|
||||
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
|
||||
WebHost.CreateDefaultBuilder(args)
|
||||
.UseApplicationInsights()
|
||||
.UseStartup<Startup>()
|
||||
.Build();
|
||||
.ConfigureLogging(
|
||||
builder =>
|
||||
{
|
||||
builder.AddApplicationInsights();
|
||||
builder.AddFilter<ApplicationInsightsLoggerProvider>("ServerlessLibrary.Program", LogLevel.Information);
|
||||
builder.AddFilter<ApplicationInsightsLoggerProvider>("ServerlessLibrary.Startup", LogLevel.Information);
|
||||
builder.AddFilter<ApplicationInsightsLoggerProvider>("", LogLevel.Information);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -15,6 +15,14 @@
|
|||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"IIS Express - API only": {
|
||||
"commandName": "IISExpress",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"ApiOnly": "true"
|
||||
}
|
||||
},
|
||||
"ServerLessLibrary": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
|
|
|
@ -1,43 +1,64 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netcoreapp2.1</TargetFramework>
|
||||
<TypeScriptCompileBlocked>true</TypeScriptCompileBlocked>
|
||||
<TypeScriptToolsVersion>Latest</TypeScriptToolsVersion>
|
||||
<IsPackable>false</IsPackable>
|
||||
<SpaRoot>ClientApp\</SpaRoot>
|
||||
<DefaultItemExcludes>$(DefaultItemExcludes);$(SpaRoot)node_modules\**</DefaultItemExcludes>
|
||||
<ApplicationInsightsResourceId>/subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary</ApplicationInsightsResourceId>
|
||||
<ApplicationInsightsAnnotationResourceId>/subscriptions/7c1b7bab-00b2-4cb7-924e-205c4f411810/resourcegroups/Default-ApplicationInsights-EastUS/providers/microsoft.insights/components/ServerlessLibrary</ApplicationInsightsAnnotationResourceId>
|
||||
<Company>Microsoft</Company>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<UserSecretsId>235c2497-239d-47f0-8ea7-af2dd2416d95</UserSecretsId>
|
||||
<RootNamespace>ServerlessLibrary</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="wwwroot\" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.5.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.All" Version="2.1.6" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Abstractions" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc" Version="2.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.Core" Version="2.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Https" Version="2.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.StaticFiles" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="2.1.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.ApplicationInsights.AspNetCore" Version="2.6.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Razor.Design" Version="2.1.2" PrivateAssets="All" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="2.1.1" />
|
||||
<PackageReference Include="Microsoft.Azure.DocumentDB.Core" Version="2.2.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.ApplicationInsights" Version="2.9.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="4.0.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="4.0.1" />
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager" Version="4.5.0" />
|
||||
<PackageReference Include="System.Linq" Version="4.3.0" />
|
||||
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Don't publish the SPA source files, but do show them in the project files list -->
|
||||
<Content Remove="$(SpaRoot)**" />
|
||||
<None Include="$(SpaRoot)**" Exclude="$(SpaRoot)node_modules\**" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<WCFMetadata Include="Connected Services" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="DebugEnsureNodeEnv" BeforeTargets="Build" Condition=" '$(Configuration)' == 'Debug' And !Exists('$(SpaRoot)node_modules') ">
|
||||
<!-- Ensure Node.js is installed -->
|
||||
<Exec Command="node --version" ContinueOnError="true">
|
||||
<Output TaskParameter="ExitCode" PropertyName="ErrorCode" />
|
||||
</Exec>
|
||||
<Error Condition="'$(ErrorCode)' != '0'" Text="Node.js is required to build and run this project. To continue, please install Node.js from https://nodejs.org/, and then restart your command prompt or IDE." />
|
||||
<Message Importance="high" Text="Restoring dependencies using 'npm'. This may take several minutes..." />
|
||||
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
|
||||
</Target>
|
||||
|
||||
<Target Name="PublishRunWebpack" AfterTargets="ComputeFilesToPublish">
|
||||
<!-- As part of publishing, ensure the JS resources are freshly built in production mode -->
|
||||
<Exec WorkingDirectory="$(SpaRoot)" Command="npm install" />
|
||||
<Exec WorkingDirectory="$(SpaRoot)" Command="npm run build" />
|
||||
|
||||
<!-- Include the newly-built files in the publish output -->
|
||||
<ItemGroup>
|
||||
<DistFiles Include="$(SpaRoot)build\**" />
|
||||
<ResolvedFileToPublish Include="@(DistFiles->'%(FullPath)')" Exclude="@(ResolvedFileToPublish)">
|
||||
<RelativePath>%(DistFiles.Identity)</RelativePath>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</ResolvedFileToPublish>
|
||||
</ItemGroup>
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
|
|
@ -21,7 +21,10 @@ namespace ServerlessLibrary
|
|||
public static string SLAppInsightsKey { get { return config(""); } }
|
||||
public static int SLCacheRefreshIntervalInSeconds { get { return Int32.Parse(config("60")); } }
|
||||
public static string CACHE_ENTRY = "_CacheEntry";
|
||||
|
||||
public static string CosmosEndpoint { get { return config(); } }
|
||||
public static string CosmosAuthkey { get { return config(); } }
|
||||
public static string Database { get { return "serverlesslibrary"; } }
|
||||
public static string Collection { get { return "contributions"; } }
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -1,25 +1,53 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.SpaServices.ReactDevelopmentServer;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ServerlessLibrary.OAuth.GitHub;
|
||||
using Swashbuckle.AspNetCore.Swagger;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
public class Startup
|
||||
{
|
||||
public Startup(IConfiguration configuration)
|
||||
{
|
||||
Configuration = configuration;
|
||||
}
|
||||
|
||||
public IConfiguration Configuration { get; }
|
||||
|
||||
// This method gets called by the runtime. Use this method to add services to the container.
|
||||
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
|
||||
public void ConfigureServices(IServiceCollection services)
|
||||
{
|
||||
services.AddMemoryCache();
|
||||
services.AddMvc();
|
||||
|
||||
services.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultChallengeScheme = GitHubAuthenticationDefaults.AuthenticationScheme;
|
||||
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddCookie()
|
||||
.AddOAuth<GitHubAuthenticationOptions, GitHubAuthenticationHandler>(
|
||||
GitHubAuthenticationDefaults.AuthenticationScheme,
|
||||
GitHubAuthenticationDefaults.DisplayName,
|
||||
options => {
|
||||
options.ClientId = Configuration["Authentication:GitHub:ClientId"]; // these settings need to be present in appSettings (or in secrets.json)
|
||||
options.ClientSecret = Configuration["Authentication:GitHub:ClientSecret"];
|
||||
});
|
||||
|
||||
services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1);
|
||||
|
||||
// In production, the React files will be served from this directory
|
||||
services.AddSpaStaticFiles(configuration =>
|
||||
{
|
||||
configuration.RootPath = "ClientApp/build";
|
||||
});
|
||||
|
||||
services.AddSwaggerGen(c =>
|
||||
{
|
||||
|
@ -29,28 +57,44 @@ namespace ServerlessLibrary
|
|||
Version = "v1"
|
||||
});
|
||||
});
|
||||
//#region snippet_ConfigureApiBehaviorOptions
|
||||
//services.Configure<ApiBehaviorOptions>(options =>
|
||||
//{
|
||||
// options.SuppressConsumesConstraintForFormFileParameters = true;
|
||||
// options.SuppressInferBindingSourcesForParameters = true;
|
||||
// options.SuppressModelStateInvalidFilter = true;
|
||||
//});
|
||||
//#endregion
|
||||
|
||||
services.AddSingleton<ICacheService, CacheService>();
|
||||
services.AddSingleton<ILibraryStore, CosmosLibraryStore>();
|
||||
}
|
||||
|
||||
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
|
||||
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
|
||||
{
|
||||
app.UseHsts();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseSpaStaticFiles();
|
||||
app.UseSwaggerUI(c =>
|
||||
{
|
||||
c.SwaggerEndpoint("/swagger/v1/swagger.json", "Serverless library API v1");
|
||||
c.RoutePrefix = "swagger";
|
||||
});
|
||||
app.UseMvc();
|
||||
|
||||
app.UseAuthentication();
|
||||
|
||||
app.UseMvc(routes =>
|
||||
{
|
||||
routes.MapRoute(
|
||||
name: "default",
|
||||
template: "{controller}/{action=Index}/{id?}");
|
||||
});
|
||||
|
||||
app.UseSpa(spa =>
|
||||
{
|
||||
spa.Options.SourcePath = "ClientApp";
|
||||
|
||||
if (env.IsDevelopment())
|
||||
{
|
||||
spa.UseReactDevelopmentServer(npmScript: "start");
|
||||
}
|
||||
});
|
||||
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers.Add("X-Frame-Options", "SAMEORIGIN");
|
||||
|
@ -59,7 +103,6 @@ namespace ServerlessLibrary
|
|||
context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
|
||||
await next();
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,69 +1,80 @@
|
|||
using Microsoft.WindowsAzure.Storage;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.WindowsAzure.Storage;
|
||||
using Microsoft.WindowsAzure.Storage.Queue;
|
||||
using Microsoft.WindowsAzure.Storage.RetryPolicies;
|
||||
using Microsoft.WindowsAzure.Storage.Table;
|
||||
using Microsoft.WindowsAzure.Storage.Queue;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
|
||||
namespace ServerlessLibrary {
|
||||
namespace ServerlessLibrary
|
||||
{
|
||||
/// <summary>
|
||||
/// Summary description for StorageHelper
|
||||
/// </summary>
|
||||
public class StorageHelper
|
||||
{
|
||||
private const string slItemTableName = "slitemstats";
|
||||
private static readonly TableRequestOptions tableRequestRetry = new TableRequestOptions { RetryPolicy = new LinearRetry(TimeSpan.FromSeconds(2), 3) };
|
||||
private const string slContributionRequests = "contribution-requests";
|
||||
private static readonly TableRequestOptions tableRequestRetry =
|
||||
new TableRequestOptions { RetryPolicy = new LinearRetry(TimeSpan.FromSeconds(2), 3) };
|
||||
|
||||
private static CloudTableClient tableClient()
|
||||
{
|
||||
// Retrieve storage account from connection string.
|
||||
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(ServerlessLibrarySettings.SLStorageString);
|
||||
CloudStorageAccount storageAccount =
|
||||
CloudStorageAccount.Parse(ServerlessLibrarySettings.SLStorageString);
|
||||
|
||||
// Create the table client.
|
||||
return storageAccount.CreateCloudTableClient();
|
||||
|
||||
}
|
||||
|
||||
private static CloudQueueClient cloudQueueClient()
|
||||
{
|
||||
// Retrieve storage account from connection string.
|
||||
CloudStorageAccount storageAccount = CloudStorageAccount.Parse(ServerlessLibrarySettings.SLStorageString);
|
||||
CloudStorageAccount storageAccount =
|
||||
CloudStorageAccount.Parse(ServerlessLibrarySettings.SLStorageString);
|
||||
|
||||
// Create the queue client.
|
||||
return storageAccount.CreateCloudQueueClient();
|
||||
|
||||
}
|
||||
|
||||
private static async Task<CloudTable> getTableReference(string tableName = slItemTableName)
|
||||
{
|
||||
CloudTable table = tableClient().GetTableReference(tableName);
|
||||
await table.CreateIfNotExistsAsync();
|
||||
return table;
|
||||
}
|
||||
private static async Task<CloudQueue> getQueueReference(string queueName = slItemTableName)
|
||||
|
||||
private static async Task<CloudQueue> getQueueReference(string queueName)
|
||||
{
|
||||
CloudQueue queue = cloudQueueClient().GetQueueReference(queueName);
|
||||
await queue.CreateIfNotExistsAsync();
|
||||
return queue;
|
||||
}
|
||||
public static async void updateDownloadCount(string templateName)
|
||||
|
||||
public static async Task submitContributionForApproval(string contributionPayload)
|
||||
{
|
||||
var message = new CloudQueueMessage(templateName);
|
||||
message.SetMessageContent(templateName);
|
||||
await (await getQueueReference()).AddMessageAsync(message);
|
||||
var message = new CloudQueueMessage(contributionPayload);
|
||||
await (await getQueueReference(slContributionRequests)).AddMessageAsync(message);
|
||||
}
|
||||
|
||||
public static async Task updateUserStats(string statsPayload)
|
||||
{
|
||||
var message = new CloudQueueMessage(statsPayload);
|
||||
await (await getQueueReference(slItemTableName)).AddMessageAsync(message);
|
||||
}
|
||||
|
||||
public static async Task<IEnumerable<SLItemStats>> getSLItemRecordsAsync()
|
||||
{
|
||||
|
||||
TableQuery<SLItemStats> query = new TableQuery<SLItemStats>().Select(new List<string> { "template", "totalDownloads"
|
||||
,"downloadsToday","downloadsThisWeek","downloadsThisMonth"});
|
||||
TableQuery<SLItemStats> query = new TableQuery<SLItemStats>()
|
||||
.Select(new List<string> { "id", "totalDownloads", "likes", "dislikes" });
|
||||
TableContinuationToken continuationToken = null;
|
||||
List<SLItemStats> entities = new List<SLItemStats>();
|
||||
var opContext = new OperationContext();
|
||||
do
|
||||
{
|
||||
TableQuerySegment<SLItemStats>
|
||||
queryResults = await (await getTableReference()).ExecuteQuerySegmentedAsync<SLItemStats>(query, continuationToken, tableRequestRetry, opContext);
|
||||
TableQuerySegment<SLItemStats> queryResults =
|
||||
await (await getTableReference()).ExecuteQuerySegmentedAsync<SLItemStats>(query, continuationToken, tableRequestRetry, opContext);
|
||||
continuationToken = queryResults.ContinuationToken;
|
||||
entities.AddRange(queryResults.Results);
|
||||
|
||||
|
@ -71,25 +82,24 @@ namespace ServerlessLibrary {
|
|||
return entities;
|
||||
}
|
||||
|
||||
public async Task<SLItemStats> GetItem(string template)
|
||||
public async Task<SLItemStats> GetItem(string id)
|
||||
{
|
||||
TableOperation operation = TableOperation.Retrieve<SLItemStats>(template, template);
|
||||
TableOperation operation = TableOperation.Retrieve<SLItemStats>(id, id);
|
||||
|
||||
TableResult result = await (await getTableReference()).ExecuteAsync(operation);
|
||||
|
||||
return (SLItemStats)(dynamic)result.Result;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public class SLItemStats : TableEntity
|
||||
{
|
||||
public string template { get; set; }
|
||||
public string id { get; set; }
|
||||
public int totalDownloads { get; set; }
|
||||
public int downloadsToday { get; set; }
|
||||
public int downloadsThisWeek { get; set; }
|
||||
public int downloadsThisMonth { get; set; }
|
||||
public DateTime lastUpdated { get; set; }
|
||||
public int likes { get; set; }
|
||||
public int dislikes { get; set; }
|
||||
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Debug",
|
||||
"System": "Information",
|
||||
"Microsoft": "Information"
|
||||
}
|
||||
},
|
||||
"ApplicationInsights": {
|
||||
"InstrumentationKey": ""
|
||||
}
|
||||
}
|
|
@ -1,5 +1,17 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ApplicationInsights": {
|
||||
"InstrumentationKey": "d35b5caf-a276-467c-9ac7-f7f7d84ea171"
|
||||
},
|
||||
"Authentication": {
|
||||
"GitHub": {
|
||||
"ClientId": "",
|
||||
"ClientSecret": ""
|
||||
}
|
||||
}
|
||||
}
|
Различия файлов скрыты, потому что одна или несколько строк слишком длинны
|
@ -1,11 +0,0 @@
|
|||
<!DOCTYPE html><html class=no-js lang=en dir=ltr><head><meta charset=utf-8><meta name=viewport content="width=device-width,initial-scale=1"><link rel=icon href=/favicon.ico><meta http-equiv=x-ua-compatible content="ie=edge"><meta name=description content="Azure Functions and Logic Apps Samples"><meta name=keywords content="Serverless Functions Logic Apps Azure"><meta name=author content="Microsoft Corporation"><title>Azure Serverless Community Library</title><link rel=stylesheet type=text/css href=//assets.onestore.ms/cdnfiles/external/mwf/long/v1/v1.26.0/css/mwf-west-european-default.min.css><script>var aiInstrumentationKey="d35b5caf-a276-467c-9ac7-f7f7d84ea171";
|
||||
if (aiInstrumentationKey !== "")
|
||||
{
|
||||
var appInsights=window.appInsights||function(a){
|
||||
function b(a){c[a]=function(){var b=arguments;c.queue.push(function(){c[a].apply(c,b)})}}var c={config:a},d=document,e=window;setTimeout(function(){var b=d.createElement("script");b.src=a.url||"https://az416426.vo.msecnd.net/scripts/a/ai.0.js",d.getElementsByTagName("script")[0].parentNode.appendChild(b)});try{c.cookie=d.cookie}catch(a){}c.queue=[];for(var f=["Event","Exception","Metric","PageView","Trace","Dependency"];f.length;)b("track"+f.pop());if(b("setAuthenticatedUserContext"),b("clearAuthenticatedUserContext"),b("startTrackEvent"),b("stopTrackEvent"),b("startTrackPage"),b("stopTrackPage"),b("flush"),!a.disableExceptionTracking){f="onerror",b("_"+f);var g=e[f];e[f]=function(a,b,d,e,h){var i=g&&g(a,b,d,e,h);return!0!==i&&c["_"+f](a,b,d,e,h),i}}return c
|
||||
}({
|
||||
instrumentationKey: aiInstrumentationKey
|
||||
});
|
||||
|
||||
window.appInsights=appInsights,appInsights.queue&&0===appInsights.queue.length&&appInsights.trackPageView();
|
||||
}</script><link as=style href=/css/app.97755866.css rel=preload><link as=script href=/js/app.e39284d8.js rel=preload><link as=script href=/js/chunk-vendors.540697dd.js rel=preload><link href=/css/app.97755866.css rel=stylesheet></head><body><a class=m-skip-to-main href=#mainContent tabindex=0>Skip to main content</a><noscript><strong>We're sorry but functions-samples doesn't work properly without JavaScript enabled. Please enable it to continue.</strong></noscript><div id=app class=f-background-neutral-20></div><script src=/js/chunk-vendors.540697dd.js></script><script src=/js/app.e39284d8.js></script></body><script src=//assets.onestore.ms/cdnfiles/external/mwf/short/v1/latest/scripts/mwf-auto-init-main.var.min.js></script></html>
|
Некоторые файлы не были показаны из-за слишком большого количества измененных файлов Показать больше
Загрузка…
Ссылка в новой задаче