Merge pull request #15 from bdfin/react-refactor

React refactor
This commit is contained in:
2024-04-30 21:38:37 +01:00
committed by GitHub
122 changed files with 5462 additions and 3669 deletions

View File

@@ -28,9 +28,8 @@ jobs:
action: "upload"
###### Repository/Build Configurations - These values can be configured to match your app requirements. ######
# For more information regarding Static Web App workflow configurations, please visit: https://aka.ms/swaworkflowconfig
app_location: "/BeauFindlay/src/BeauFindlay.Client" # App source code path
api_location: "/BeauFindlay/src/BeauFindlay.Api" # Api source code path - optional
output_location: "wwwroot" # Built app content directory - optional
app_location: "/src/Client" # App source code path
output_location: "dist" # Built app content directory - optional
###### End of Repository/Build Configurations ######
close_pull_request_job:

406
.gitignore vendored
View File

@@ -1,406 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# 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
# Note: 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
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# 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
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# 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
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# 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
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
/BeauFindlay/BeauFindlay/package-lock.json
/BeauFindlay/BeauFindlay/package.json
/BeauFindlay/src/BeauFindlay.Client/package-lock.json
/BeauFindlay/src/BeauFindlay.Client/package.json
/BeauFindlay/src/BeauFindlay.Client/package-lock.json
/BeauFindlay/src/BeauFindlay.Client/package.json
/BeauFindlay/src/BeauFindlay.Client/package-lock.json
/BeauFindlay/src/BeauFindlay.Client/package.json

View File

@@ -1,13 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/modules.xml
/projectSettingsUpdater.xml
/.idea.BeauFindlay.iml
/contentModel.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -1,44 +0,0 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.9.34622.214
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3407557D-A21B-4F48-930C-6FDCE961ED2A}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BeauFindlay.Client", "src\BeauFindlay.Client\BeauFindlay.Client.csproj", "{979CCAA2-5F1B-457B-9536-02F94BC98F9F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Api", "src\BeauFindlay.Api\BeauFindlay.Api.csproj", "{D2F248BF-8487-4CE7-B6AA-D558587A52DA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BeauFindlay.Shared", "src\BeauFindlay.Shared\BeauFindlay.Shared.csproj", "{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{979CCAA2-5F1B-457B-9536-02F94BC98F9F}.Release|Any CPU.Build.0 = Release|Any CPU
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D2F248BF-8487-4CE7-B6AA-D558587A52DA}.Release|Any CPU.Build.0 = Release|Any CPU
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{979CCAA2-5F1B-457B-9536-02F94BC98F9F} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
{D2F248BF-8487-4CE7-B6AA-D558587A52DA} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
{0A17E6ED-1B40-4FAE-94D5-1255C3569F6E} = {3407557D-A21B-4F48-930C-6FDCE961ED2A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {6D05FF39-4D7B-4F0D-8136-E48F71106C3A}
EndGlobalSection
EndGlobal

View File

@@ -1,264 +0,0 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
# Azure Functions localsettings file
local.settings.json
# 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/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
# Visual Studio 2015 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# 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
# DNX
project.lock.json
project.fragment.lock.json
artifacts/
*_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
*.VC.db
*.VC.VC.opendb
# 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
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# 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
# NuGet v3's project.json files produces more ignoreable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
# 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
*.jfm
*.pfx
*.publishsettings
node_modules/
orleans.codegen.cs
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# 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
paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc

View File

@@ -1,16 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface ICommand : IRequest<Result>, IBaseCommand
{
}
public interface ICommand<TReponse> : IRequest<Result<TReponse>>, IBaseCommand
{
}
public interface IBaseCommand
{
}

View File

@@ -1,14 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface ICommandHandler<TCommand> : IRequestHandler<TCommand, Result>
where TCommand : ICommand
{
}
public interface ICommandHandler<TCommand, TResponse> : IRequestHandler<TCommand, Result<TResponse>>
where TCommand : ICommand<TResponse>
{
}

View File

@@ -1,7 +0,0 @@
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IDomainEvent : INotification
{
}

View File

@@ -1,8 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IQuery<TResponse> : IRequest<Result<TResponse>>
{
}

View File

@@ -1,9 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using MediatR;
namespace BeauFindlay.Api.Abstractions.Messaging;
public interface IQueryHandler<TQuery, TResponse> : IRequestHandler<TQuery, Result<TResponse>>
where TQuery : IQuery<TResponse>
{
}

View File

@@ -1,35 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<AzureFunctionsVersion>v4</AzureFunctionsVersion>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.9.0" />
<PackageReference Include="MediatR" Version="12.2.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker" Version="1.20.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Extensions.Http" Version="3.1.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.Sdk" Version="1.16.2" />
<PackageReference Include="Microsoft.ApplicationInsights.WorkerService" Version="2.21.0" />
<PackageReference Include="Microsoft.Azure.Functions.Worker.ApplicationInsights" Version="1.0.0" />
<PackageReference Include="SendGrid" Version="9.29.2" />
<PackageReference Include="SendGrid.Extensions.DependencyInjection" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<None Update="host.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="local.settings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<CopyToPublishDirectory>Never</CopyToPublishDirectory>
</None>
</ItemGroup>
<ItemGroup>
<Using Include="System.Threading.ExecutionContext" Alias="ExecutionContext" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,45 +0,0 @@
using BeauFindlay.Api.Features.Contact;
using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using SendGrid.Extensions.DependencyInjection;
namespace BeauFindlay.Api.Extensions;
public static class ServiceCollectionExtensions
{
public static void AddApplicationServices(this IServiceCollection services)
{
var assembly = typeof(ServiceCollectionExtensions).Assembly;
services.AddMediatR(config => { config.RegisterServicesFromAssembly(assembly); });
services.AddValidatorsFromAssembly(assembly, includeInternalTypes: true);
services.AddEmailService();
services.AddRecaptchaService();
}
private static void AddEmailService(this IServiceCollection services)
{
var apiKey = Environment.GetEnvironmentVariable("SendGridApiKey")
?? throw new ArgumentException("SendGrid API key cannot be null");
services.AddSendGrid(config => config.ApiKey = apiKey);
services.AddScoped<ISendGridService, SendGridService>();
}
private static void AddRecaptchaService(this IServiceCollection services)
{
var apiKey = Environment.GetEnvironmentVariable("RecaptchaApiKey")
?? throw new ArgumentException("Google Recaptcha API key cannot be null");
var settings = new RecaptchaSettings(apiKey);
services.AddSingleton(settings);
services.AddHttpClient<IRecaptchaService, RecaptchaService>();
}
}

View File

@@ -1,9 +0,0 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface IRecaptchaService
{
Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default);
}

View File

@@ -1,9 +0,0 @@
using BeauFindlay.Shared.Abstractions;
namespace BeauFindlay.Api.Features.Contact;
internal interface ISendGridService
{
Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent);
}

View File

@@ -1,93 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class RecaptchaService(
HttpClient httpClient,
ILogger<RecaptchaService> logger,
RecaptchaSettings settings)
: IRecaptchaService
{
public async Task<Result> ValidateResponseAsync(string recaptchaResponse,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(recaptchaResponse))
{
logger.LogWarning("Recaptcha response is null.");
return Result.Failure(RecaptchaErrors.ResponseNull);
}
var response = await httpClient.PostAsync(
$"https://www.google.com/recaptcha/api/siteverify?secret={settings.ApiKey}&response={recaptchaResponse}",
null, cancellationToken);
if (!response.IsSuccessStatusCode)
{
logger.LogError(
"Google Recaptcha API validation request failed. Code: {StatusCode}",
response.StatusCode);
return Result.Failure(RecaptchaErrors.ApiRequestFailed);
}
var responseString = await response.Content.ReadAsStringAsync(cancellationToken);
var recaptchaResult = JsonConvert.DeserializeObject<RecaptchaVerificationResult>(responseString);
if (recaptchaResult is null)
{
logger.LogError("Unable to deserialize Recaptcha result.");
return Result.Failure(RecaptchaErrors.ResponseSerializationFailed);
}
if (!recaptchaResult.Success)
{
logger.LogWarning(
"Google Recaptcha validation failed. Errors: {Errors}",
recaptchaResult.ErrorCodes);
return Result.Failure(RecaptchaErrors.ValidationFailed);
}
logger.LogInformation("Recaptcha validation passed.");
return Result.Success();
}
private class RecaptchaVerificationResult
{
[JsonProperty("success")]
public bool Success { get; set; }
[JsonProperty("challenge_ts")]
public DateTime ChallengeTs { get; set; }
[JsonProperty("hostname")]
public string Hostname { get; set; } = string.Empty;
[JsonProperty("error-codes")]
public List<string> ErrorCodes { get; set; } = [];
}
}
public static class RecaptchaErrors
{
public static readonly Error ResponseNull = new(
"Recaptcha.ResponseNull",
"Recaptcha response is null.");
public static readonly Error ValidationFailed = new(
"Recaptcha.ValidationFailed",
"Recaptcha validation failed.");
public static readonly Error ResponseSerializationFailed = new(
"Recaptcha.ResponseSerializationFailed",
"Unable to deserialize Recaptcha result.");
public static readonly Error ApiRequestFailed = new(
"Recaptcha.ApiRequestFailed",
"Recaptcha API validation request failed.");
}

View File

@@ -1,3 +0,0 @@
namespace BeauFindlay.Api.Features.Contact;
internal sealed record RecaptchaSettings(string ApiKey);

View File

@@ -1,71 +0,0 @@
using BeauFindlay.Api.Abstractions.Messaging;
using BeauFindlay.Shared.Abstractions;
using FluentValidation;
using Microsoft.Extensions.Logging;
namespace BeauFindlay.Api.Features.Contact;
public sealed record SendContactEmailCommand(string Name, string FromEmail, string Message, string RecaptchaResponse)
: ICommand;
internal sealed class SendContactEmailCommandValidator : AbstractValidator<SendContactEmailCommand>
{
public SendContactEmailCommandValidator(IRecaptchaService recaptchaService)
{
RuleFor(c => c.Name)
.NotEmpty()
.MaximumLength(50);
RuleFor(c => c.FromEmail)
.NotEmpty()
.EmailAddress();
RuleFor(c => c.Message)
.NotEmpty()
.MaximumLength(500);
RuleFor(c => c.RecaptchaResponse)
.NotEmpty()
.MustAsync(async (response, cancellation) =>
{
var validationResult = await recaptchaService.ValidateResponseAsync(response, cancellation);
return validationResult.IsSuccess;
});
}
}
internal sealed class SendContactEmailCommandHandler(
ISendGridService sendGridService,
IValidator<SendContactEmailCommand> validator,
ILogger<SendContactEmailCommandHandler> logger)
: ICommandHandler<SendContactEmailCommand>
{
private const string EmailSubjectBase = "New website enquiry";
private const string MyEmail = "me@beaufindlay.com";
public async Task<Result> Handle(SendContactEmailCommand request, CancellationToken cancellationToken)
{
var validationResult = await validator.ValidateAsync(request, cancellationToken);
if (!validationResult.IsValid)
{
logger.LogError("Command validation failed. Errors: {ValidationErrors}", validationResult.ToString());
return Result.Failure(new Error("ValidationFailed", "Command validation failed"));
}
var subject = $"{EmailSubjectBase} - {request.FromEmail}";
var message = $"From: {request.FromEmail}. Message: {request.Message}";
var htmlMessage = $"<p><b>From:</b> {request.FromEmail}</p> <p><b>Message:</b> <br /> {request.Message}</p>";
var emailResult = await sendGridService.SendEmailAsync(
MyEmail,
MyEmail,
subject,
message,
htmlMessage);
return emailResult.IsFailure ? Result.Failure(emailResult.Error) : Result.Success();
}
}

View File

@@ -1,76 +0,0 @@
using System.Collections.Generic;
using System.Net;
using System.Security.Cryptography;
using BeauFindlay.Shared.Contracts;
using MediatR;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Azure.Functions.Worker.Http;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace BeauFindlay.Api.Features.Contact;
public class SendContactEmailFunction(ILoggerFactory loggerFactory, ISender sender)
{
private readonly ILogger logger = loggerFactory.CreateLogger<SendContactEmailFunction>();
[Function(nameof(SendContactEmailFunction))]
public async Task<HttpResponseData> Run(
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "send-contact-email")] HttpRequestData req,
FunctionContext executionContext, CancellationToken cancellationToken)
{
logger.LogInformation($"{nameof(SendContactEmailFunction)} function received a request.");
var requestBody = await new StreamReader(req.Body).ReadToEndAsync(cancellationToken);
var request = JsonConvert.DeserializeObject<SendContactEmailRequest>(requestBody);
HttpResponseData response;
try
{
if (request == null)
{
throw new ApplicationException("Unable to deserialize response.");
}
var emailCommand = new SendContactEmailCommand(
request.Name,
request.FromEmail,
request.Message,
request.RecaptchaResponse);
var sendEmailResult = await sender.Send(emailCommand, cancellationToken);
if (sendEmailResult.IsFailure)
{
logger.LogError("Send email command failed. Error: {Error}", sendEmailResult.Error.Message);
response = req.CreateResponse(HttpStatusCode.BadRequest);
var error = new ErrorResponse
{
Code = (int)HttpStatusCode.BadRequest,
Message = sendEmailResult.Error.Message
};
await response.WriteAsJsonAsync(error, cancellationToken);
}
else
{
response = req.CreateResponse(HttpStatusCode.OK);
await response.WriteAsJsonAsync("", cancellationToken: cancellationToken);
}
}
catch (Exception e)
{
logger.LogError(e, "Exception occured. Error: '{Message}'", e.Message);
response = req.CreateResponse(HttpStatusCode.InternalServerError);
await response.WriteAsJsonAsync(ErrorResponse.Generic, cancellationToken);
}
return response;
}
}

View File

@@ -1,38 +0,0 @@
using BeauFindlay.Shared.Abstractions;
using Microsoft.Extensions.Logging;
using SendGrid;
using SendGrid.Helpers.Mail;
namespace BeauFindlay.Api.Features.Contact;
internal sealed class SendGridService(ISendGridClient sendGridClient, ILogger<SendGridService> logger)
: ISendGridService
{
public async Task<Result> SendEmailAsync(string from, string to, string subject, string plainTextContent,
string htmlContent)
{
ArgumentException.ThrowIfNullOrWhiteSpace(from, nameof(from));
ArgumentException.ThrowIfNullOrWhiteSpace(to, nameof(to));
ArgumentException.ThrowIfNullOrWhiteSpace(subject, nameof(subject));
ArgumentException.ThrowIfNullOrWhiteSpace(plainTextContent, nameof(plainTextContent));
ArgumentException.ThrowIfNullOrWhiteSpace(htmlContent, nameof(htmlContent));
var fromEmail = new EmailAddress(from);
var toEmail = new EmailAddress(to);
var message = MailHelper.CreateSingleEmail(fromEmail, toEmail, subject, plainTextContent, htmlContent);
var response = await sendGridClient.SendEmailAsync(message);
if (response is not { IsSuccessStatusCode: true })
{
logger.LogError("Failed to send email. Status code: '{StatusCode}'", response?.StatusCode);
return Result.Failure(new Error("Email.SendFailed", "Failed to send email."));
}
logger.LogInformation("Email sent successfully.");
return Result.Success();
}
}

View File

@@ -1,16 +0,0 @@
using BeauFindlay.Api.Extensions;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var host = new HostBuilder()
.ConfigureFunctionsWorkerDefaults()
.ConfigureServices(services =>
{
services.AddApplicationInsightsTelemetryWorkerService();
services.ConfigureFunctionsApplicationInsights();
services.AddApplicationServices();
})
.Build();
host.Run();

View File

@@ -1,9 +0,0 @@
{
"profiles": {
"BeauFindlay.Api": {
"commandName": "Project",
"commandLineArgs": "--port 7071",
"launchBrowser": false
}
}
}

View File

@@ -1,11 +0,0 @@
{
"dependencies": {
"appInsights1": {
"type": "appInsights"
},
"storage1": {
"type": "storage",
"connectionId": "AzureWebJobsStorage"
}
}
}

View File

@@ -1,11 +0,0 @@
{
"dependencies": {
"appInsights1": {
"type": "appInsights.sdk"
},
"storage1": {
"type": "storage.emulator",
"connectionId": "AzureWebJobsStorage"
}
}
}

View File

@@ -1,12 +0,0 @@
{
"version": "2.0",
"logging": {
"applicationInsights": {
"samplingSettings": {
"isEnabled": true,
"excludedTypes": "Request"
},
"enableLiveMetricsFilters": true
}
}
}

View File

@@ -1,13 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/contentModel.xml
/modules.xml
/.idea.BeauFindlay.iml
/projectSettingsUpdater.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -1 +0,0 @@
BeauFindlay

View File

@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding" addBOMForNewFiles="with BOM under Windows, with no BOM otherwise" />
</project>

View File

@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../../.." vcs="Git" />
</component>
</project>

View File

@@ -1,11 +0,0 @@
<Router AppAssembly="@typeof(App).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>

View File

@@ -1,18 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="8.0.2" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BeauFindlay.Shared\BeauFindlay.Shared.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,46 +0,0 @@
<div class="border-0 px-8 py-6 bg-black shadow ring-1 ring-inset ring-gray-300">
<div class="flex">
<div class="flex-shrink-0">
@if (Type == AlertType.Success)
{
<svg class="h-5 w-5 text-green-500" v iewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clip-rule="evenodd"/>
</svg>
}
else
{
<svg class="h-5 w-5 text-red-500" v iewBox="0 0 20 20"
fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z"
clip-rule="evenodd"/>
</svg>
}
</div>
<div class="ml-3">
<h3 class="font-medium">
@Title
</h3>
<div class="mt-2">
<p>@ChildContent</p>
</div>
</div>
</div>
</div>
@code {
[Parameter]
public string Title { get; set; } = string.Empty;
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public AlertType Type { get; set; }
}

View File

@@ -1,7 +0,0 @@
namespace BeauFindlay.Client.Components.Alert;
public enum AlertType
{
Success,
Error
}

View File

@@ -1,18 +0,0 @@
<a href="@Href"
target="@Target"
class="underline underline-offset-2">
@ChildContent
</a>
@code {
[Parameter]
public string Href { get; set; } = string.Empty;
[Parameter]
public string Target { get; set; } = "_blank";
[Parameter]
public RenderFragment? ChildContent { get; set; }
}

View File

@@ -1,24 +0,0 @@
<button type="@Type" disabled="@IsLoading"
class="border-0 ring-1 ring-inset ring-gray-300 bg-black px-3.5 py-2.5 text-sm font-semibold text-white shadow hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 disabled:bg-gray-800 disabled:cursor-progress">
@if (IsLoading)
{
<LoadingSpinner Size="LoadingSpinnerSize.Small"/>
}
else
{
@ChildContent
}
</button>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string Type { get; set; } = "button";
[Parameter]
public bool IsLoading { get; set; }
}

View File

@@ -1,39 +0,0 @@
<div role="status">
<svg aria-hidden="true"
class="@sizeCss inline text-gray-200 animate-spin dark:text-gray-600 fill-gray-600 dark:fill-gray-300"
viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"/>
</svg>
<span class="sr-only">Loading...</span>
</div>
@code {
[Parameter]
public LoadingSpinnerSize Size { get; set; }
private string sizeCss = "w-8 h-8";
protected override void OnParametersSet()
{
SetSpinnerSize();
}
private void SetSpinnerSize()
{
sizeCss = Size switch
{
LoadingSpinnerSize.Small => "w-6 h-6",
LoadingSpinnerSize.Medium => "w-8 h-8",
LoadingSpinnerSize.Large => "w-12 h-12",
_ => sizeCss
};
}
}

View File

@@ -1,8 +0,0 @@
namespace BeauFindlay.Client.Components.LoadingSpinner;
public enum LoadingSpinnerSize
{
Small,
Medium,
Large
}

View File

@@ -1,7 +0,0 @@
namespace BeauFindlay.Client.Components.Typewriter;
public interface ITypewriterNotificationService
{
event EventHandler<TypingCompletedEventArgs>? TypingCompleted;
void NotifyTypingCompleted(TypingCompletedEventArgs args);
}

View File

@@ -1,122 +0,0 @@
@using System.Timers
@inject ITypewriterNotificationService NotificationService
@if (DisplayCursor)
{
<span>@currentText<span class="blinking-cursor">|</span></span>
}
else
{
<span>@currentText</span>
}
@code {
private const int typingDelayMilliseconds = 50;
private const int lineEndDelayMilliseconds = 1000;
private static List<Typewriter> instances = [];
private static Typewriter? lastTypingInstance = null;
private string currentText = "";
private bool isTyping = false;
private bool DisplayCursor => lastTypingInstance == this;
[Parameter]
public string Text { get; set; } = "";
[Parameter]
public string? Name { get; set; }
public static event Action? OnAllTypingCompleted;
protected override void OnInitialized()
{
Text = Text.Trim();
instances.Add(this);
StartTypingIfFirst();
}
private void StartTypingIfFirst()
{
if (instances.FirstOrDefault() == this && !isTyping)
{
StartTyping();
}
}
private void StartTyping()
{
isTyping = true;
lastTypingInstance = this;
var timer = new Timer(typingDelayMilliseconds);
var index = 0;
timer.Elapsed += (_, __) =>
{
if (index < Text.Length)
{
currentText += Text[index++];
InvokeAsync(StateHasChanged);
}
else
{
CompleteTyping(timer);
}
};
timer.Start();
}
private void CompleteTyping(Timer typingTimer)
{
typingTimer.Stop();
isTyping = false;
var delayTimer = new Timer(lineEndDelayMilliseconds);
delayTimer.Elapsed += (sender, e) =>
{
delayTimer.Stop();
delayTimer.Dispose();
UpdateCursorVisibility();
StartNextInstanceTyping();
if (!string.IsNullOrWhiteSpace(Name))
{
NotificationService.NotifyTypingCompleted(new TypingCompletedEventArgs(Name));
}
if (!instances.Any())
{
OnAllTypingCompleted?.Invoke();
}
InvokeAsync(StateHasChanged);
};
delayTimer.Start();
}
private void UpdateCursorVisibility()
{
lastTypingInstance = instances.LastOrDefault(i => !i.isTyping);
InvokeAsync(StateHasChanged);
}
private void StartNextInstanceTyping()
{
instances.Remove(this);
var nextInstance = instances.FirstOrDefault();
nextInstance?.StartTyping();
}
public static void Reset()
{
instances.Clear();
lastTypingInstance = null;
}
}

View File

@@ -1,9 +0,0 @@
namespace BeauFindlay.Client.Components.Typewriter;
public static class TypewriterConstants
{
public static class Name
{
public const string IntroComplete = nameof(IntroComplete);
}
}

View File

@@ -1,13 +0,0 @@
namespace BeauFindlay.Client.Components.Typewriter;
public class TypewriterNotificationService : ITypewriterNotificationService
{
public event EventHandler<TypingCompletedEventArgs>? TypingCompleted;
public void NotifyTypingCompleted(TypingCompletedEventArgs args) => TypingCompleted?.Invoke(this, args);
}
public class TypingCompletedEventArgs(string typewriterInstanceId) : EventArgs
{
public string TypewriterInstanceId { get; set; } = typewriterInstanceId;
}

View File

@@ -1,23 +0,0 @@
<footer class="mt-auto">
<div class="mx-auto py-8">
<div class="md:flex md:items-center md:justify-between">
<div class="flex space-x-6 md:order-2">
<a href="https://github.com/bdfin" class="text-slate-200 hover:text-slate-500">
<span class="sr-only">GitHub</span>
<i class="fa-brands fa-github fa-xl"></i>
</a>
<a href="https://www.linkedin.com/in/beau-findlay/" class="text-slate-200 hover:text-slate-500">
<span class="sr-only">LinkedIn</span>
<i class="fa-brands fa-linkedin fa-xl"></i>
</a>
<a href="mailto:me@beaufindlay.com" class="text-slate-200 hover:text-slate-500">
<span class="sr-only">Email</span>
<i class="fa-regular fa-envelope fa-xl"></i>
</a>
</div>
<p class="mt-8 text-xs leading-5 text-slate-100 md:order-1 md:mt-0">
&copy; @DateTime.UtcNow.Year Beau Findlay. All rights reserved.
</p>
</div>
</div>
</footer>

View File

@@ -1,11 +0,0 @@
@inherits LayoutComponentBase
<div class="flex flex-col min-h-screen mx-auto max-w-screen-2xl fade-in px-4 md:px-12 lg:px-24">
<NavBar/>
<div class="flex-1 py-8">
@Body
</div>
<Footer></Footer>
</div>

View File

@@ -1,85 +0,0 @@
<nav class="bg-black py-4">
<div class="mx-auto">
<div class="relative flex h-20 items-center justify-between">
<div class="absolute inset-y-0 left-0 flex items-center sm:hidden">
<!-- Mobile menu button-->
<button @onclick="ToggleMenu" type="button" class="relative inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:text-white" aria-controls="mobile-menu" aria-expanded="false">
<span class="sr-only">Open main menu</span>
@if (menuOpen)
{
<i class="fa-solid fa-xmark fa-xl"></i>
}
else
{
<i class="fa-solid fa-bars fa-xl"></i>
}
</button>
</div>
<div class="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
<div class="flex flex-shrink-0 items-center">
<img class="h-16 w-auto" src="images/logo.png" alt="Your Company">
</div>
<div class="items-center hidden sm:flex sm:flex-1 justify-center">
<div class="flex space-x-4">
<NavLink href="/"
Match="NavLinkMatch.All"
ActiveClass="bg-gray-900 text-white"
class="text-gray-300 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 font-medium">
Home
</NavLink>
<NavLink href="/contact"
Match="NavLinkMatch.Prefix"
ActiveClass="bg-gray-900 text-white"
class="text-gray-300 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 font-medium">
Contact
</NavLink>
<NavLink href="/about"
Match="NavLinkMatch.Prefix"
ActiveClass="bg-gray-900 text-white"
class="text-gray-300 hover:bg-gray-800 hover:text-white rounded-md px-3 py-2 font-medium">
This App
</NavLink>
</div>
</div>
</div>
</div>
</div>
@if (menuOpen)
{
<div class="sm:hidden" id="mobile-menu">
<div class="space-y-1 px-2 pb-3 pt-2">
<NavLink href="/"
Match="NavLinkMatch.All"
ActiveClass="bg-gray-900 text-white"
class="block text-gray-300 hover:bg-gray-700 hover:text-white rounded-md px-3 py-2 font-medium">
Home
</NavLink>
<NavLink href="/contact"
Match="NavLinkMatch.Prefix"
ActiveClass="bg-gray-900 text-white"
class="block text-gray-300 hover:bg-gray-700 hover:text-white rounded-md px-3 py-2 font-medium">
Contact
</NavLink>
<NavLink href="/about"
Match="NavLinkMatch.Prefix"
ActiveClass="bg-gray-900 text-white"
class="block text-gray-300 hover:bg-gray-700 hover:text-white rounded-md px-3 py-2 font-medium">
This App
</NavLink>
</div>
</div>
}
</nav>
@code {
private bool menuOpen;
private void ToggleMenu()
{
menuOpen = !menuOpen;
}
}

View File

@@ -1,77 +0,0 @@
@page "/About"
@inject IJSRuntime JSRuntime
<PageTitle>About - Beau Findlay</PageTitle>
<div class="text-center pb-4" id="@TopSection">
<h1 class="text-4xl">This app</h1>
</div>
<nav class="flex items-center justify-center py-8 space-x-8">
<a @onclick="() => ScrollToElementAsync(FrontEndSection)" class="underline underline-offset-2 cursor-pointer">Front-end</a>
<a @onclick="() => ScrollToElementAsync(BackEndSection)" class="underline underline-offset-2 cursor-pointer">Back-end</a>
<a @onclick="() => ScrollToElementAsync(HostingSection)" class="underline underline-offset-2 cursor-pointer">Hosting</a>
</nav>
<p class="py-4 text-xl">Below is an overview of how this simple app is made and what technologies are used. If you'd like to dive straight in, the full project is available on my <Anchor Href="https://github.com/bdfin/my-portfolio">GitHub</Anchor>.</p>
<p class="pt-4 pb-8 text-xl">I'm planning to integrate a simple blog as part of this app that will dive into more specific implementation details so check back soon for more!</p>
<section class="py-12 text-lg" id="@FrontEndSection">
<h2 class="text-3xl pb-4">Front-end: <img src="images/blazor-logo.png" class="inline w-auto h-6" alt="blazor logo"/> .NET Blazor WASM</h2>
<p class="py-4">I wanted to create a decent, modern client-side experience for this app and given my <em class="text-xs">(very...)</em> limited front-end expertise I decided to choose <Anchor Href="https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor">.NET Blazor Webassembly</Anchor>. Blazor is Microsoft's take on component-based SPAs (single page applications) and offers us back-end focussed devs a way of producing decent client experiences without needing to dive into another front-end specific technology.</p>
<p class="py-4">Blazor traditionally came in two flavours, server and webassembly with an additional third option (Blazor Web App) recently released with .NET 8 which can offer the functionality of both, alongside traditional SSR (server-side rendering). <Anchor Href="https://learn.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-8.0#blazor-server">Blazor Server</Anchor> initially generates content on the server and utilises web-sockets to communicate dynamic UI updates with the client without requiring a page load, whereas <Anchor Href="https://learn.microsoft.com/en-us/aspnet/core/blazor/hosting-models?view=aspnetcore-8.0#blazor-webassembly">Blazor Webassembly</Anchor> downloads the entire app to the client browser on first load alongside a light-weight .NET run-time to execute code directly on the browsers UI thread.</p>
<p class="py-4">As Blazor server requires a dedicated server to host the application, I chose the webassembly model to enable free hosting using an <Anchor Href="https://azure.microsoft.com/en-gb/products/app-service/static">Azure Static Web App</Anchor>. You can read more about this in the <a @onclick="() => ScrollToElementAsync(HostingSection)" class="underline underline-offset-2 cursor-pointer">hosting</a> section.</p>
<p class="py-4">This app is styled using a cool CSS framework called <Anchor Href="https://tailwindcss.com/">TailwindCSS</Anchor>. <Anchor Href="https://postcss.org/">PostCSS</Anchor> is used alongside Tailwind to generate a lightweight stylesheet based only on the parts of the framework that are used, as oppose to including a everything the framework offers.</p>
</section>
<section class="py-12 text-lg" id="@BackEndSection">
<h2 class="text-3xl pb-4">Back-end: <img src="images/azure-function-logo.png" class="inline w-auto h-6" alt="azure function app logo"/> .NET Azure Functions API</h2>
<p class="py-4">There is a very minimal API used as the back-end of this app to allow users to contact me directly via the <NavLink href="/contact" class="underline underline-offset-2">contact</NavLink> page. This will be expanded to serve the technical blog I'm building as a new feature that will be available soon.</p>
<p class="pt-4 pb-2">The contact API endpoint currently:</p>
<ul class="list-disc pl-8 pb-4">
<li>Validates a <Anchor Href="https://www.google.com/recaptcha/about/">Google reCAPTCHA</Anchor> token to protect against fraudulent submissions.</li>
<li>Builds a HTML email from the information provided in the form.</li>
<li>Sends an email directly to my inbox using the <Anchor Href="https://sendgrid.com/en-us">SendGrid</Anchor> API.</li>
</ul>
<p class="py-4">The API is written in .NET 8 using <Anchor Href="https://azure.microsoft.com/en-gb/products/functions">Azure Serverless Functions</Anchor> with HTTP triggers to act as API endpoints. For larger scale projects I would almost always opt for a fully-featured <Anchor Href="https://dotnet.microsoft.com/en-us/apps/aspnet/apis">Web API</Anchor>, however Azure Functions provide automatic elastic scaling with consumption-based billing and a generous free-tier, making them perfect for smaller projects like this.</p>
</section>
<section class="py-12 text-lg" id="@HostingSection">
<h2 class="text-3xl pb-4">Hosting: <img src="images/azure-static-web-app-logo.png" class="inline w-auto h-6" alt="azure static web app logo"/> Microsoft Azure Static Web App</h2>
<p class="py-4">The goal of this project was to learn some new technologies and host the app as cheaply as possible. With this in mind I decided to go with a <Anchor Href="https://azure.microsoft.com/en-gb/products/app-service/static">Static Web App</Anchor> hosted on Microsoft Azure. Static Web Apps offer global distribution of static assets (the Blazor Webassembly app in this case) and offer integrated hosting for Azure Function App APIs.</p>
<p class="py-4">Another cool feature of Static Web Apps is Azure's integration with GitHub actions to deploy both the client and server simultaneously and provide automatically deployed staging environments for pull-requests opened to the main branch. This made testing deployed changes much easier and cheaper than deploying an isolated testing/GA environment before releasing to the live version of the app.</p>
<p class="py-4">Using Static Web Apps on Azure has meant that I have been able to build, deploy and serve this site and API completely free (with the exception of the domain name). The next thing on the roadmap is building a simple blog using an <Anchor Href="https://azure.microsoft.com/en-gb/products/azure-sql/database">Azure SQL database</Anchor> where I'll document the full process of writing and deploying this app so check back again soon.</p>
</section>
<nav class="flex items-center justify-center pb-8">
<a @onclick="() => ScrollToElementAsync(TopSection)" class="underline underline-offset-2 cursor-pointer">Top</a>
</nav>
<AnchorNavigation/>
@code {
private const string FrontEndSection = "front-end";
private const string BackEndSection = "back-end";
private const string HostingSection = "hosting";
private const string TopSection = "top";
private async Task ScrollToElementAsync(string elementId)
{
await JSRuntime.InvokeVoidAsync("scrollToElement", elementId);
}
}

View File

@@ -1,124 +0,0 @@
@page "/contact"
@inject HttpClient HttpClient
@inject IJSRuntime JSRuntime
<PageTitle>Contact - Beau Findlay</PageTitle>
<div class="text-center">
<h1 class="text-4xl">Contact</h1>
<p class="text-lg mt-8">
If you think I can help with your project or you'd just like to talk tech, send me a message!
</p>
</div>
@if (!isSubmitted)
{
<div class="mx-auto max-w-xl pt-8 pb-4">
<EditForm Model="contactInput" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator/>
<div class="grid grid-cols-1 gap-x-8 gap-y-6 sm:grid-cols-2">
<div class="sm:col-span-2">
<label for="name" class="block font-semibold leading-6">Name</label>
<div class="mt-2.5">
<InputText id="name" @bind-Value="contactInput.Name" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600" autocomplete="given-name"/>
<ValidationMessage For="() => contactInput.Name" class="text-red-600"/>
</div>
</div>
<div class="sm:col-span-2">
<label for="email" class="block font-semibold leading-6">Email</label>
<div class="mt-2.5">
<InputText id="email" @bind-Value="contactInput.Email" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600" autocomplete="email"/>
<ValidationMessage For="() => contactInput.Email" class="text-red-600"/>
</div>
</div>
<div class="sm:col-span-2">
<label for="message" class="block font-semibold leading-6">Message</label>
<div class="mt-2.5">
<InputTextArea id="message" @bind-Value="contactInput.Message" rows="4" class="block w-full text-lg border-0 px-3.5 py-2 bg-black shadow ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-gray-600"/>
<ValidationMessage For="() => contactInput.Message" class="text-red-600"/>
</div>
</div>
</div>
<div class="mt-4">
<small>This site is protected by reCAPTCHA and the Google <Anchor Href="https://policies.google.com/privacy" Target="_blank">Privacy Policy</Anchor> and <Anchor Href="https://policies.google.com/terms" Target="_blank">Terms of Service</Anchor> apply.</small>
</div>
<div class="mt-4 flex items-center justify-center">
<Button Type="submit" IsLoading="@isSubmitting">
<i class="fa-solid fa-paper-plane"></i> Send
</Button>
</div>
</EditForm>
</div>
}
else
{
<div class="m-auto max-w-xl py-14">
@if (sendEmailSuccess)
{
<Alert Type="AlertType.Success" Title="Email sent successfully">
Thanks for getting in touch! I'll get back to you as soon as I can.
</Alert>
}
else
{
<Alert Type="AlertType.Error" Title="Email sending failed">
Looks like something went wrong trying to send that email. Please try again.
</Alert>
}
</div>
}
@code {
private readonly ContactInputModel contactInput = new();
private bool isSubmitting;
private bool isSubmitted;
private bool sendEmailSuccess;
private class ContactInputModel
{
[Required(ErrorMessage = "Please enter your name.")]
[MaxLength(50, ErrorMessage = "Please use a shorter name. 50 characters max.")]
public string Name { get; set; } = string.Empty;
[Required(ErrorMessage = "Please enter your email.")]
[EmailAddress(ErrorMessage = "Please enter a valid email address.")]
public string Email { get; set; } = string.Empty;
[Required(ErrorMessage = "Please include a message.")]
[MaxLength(500, ErrorMessage = "Please enter a shorter message. 500 characters max.")]
public string Message { get; set; } = string.Empty;
}
private async Task HandleValidSubmit()
{
isSubmitting = true;
var recaptchaResponse = await JSRuntime.InvokeAsync<string>("executeRecaptcha");
if (string.IsNullOrWhiteSpace(recaptchaResponse))
{
sendEmailSuccess = false;
}
else
{
var sendEmailRequest = new SendContactEmailRequest(
contactInput.Name,
contactInput.Email,
contactInput.Message,
recaptchaResponse);
var response = await HttpClient.PostAsJsonAsync("/api/send-contact-email", sendEmailRequest);
sendEmailSuccess = response.IsSuccessStatusCode;
}
isSubmitting = false;
isSubmitted = true;
}
}

View File

@@ -1,68 +0,0 @@
@page "/"
@inject IJSRuntime JSRuntime
<PageTitle>Home - Beau Findlay</PageTitle>
@if (!hasPreviouslyRendered)
{
<h1 class="text-4xl">
<Typewriter Text="Hi, I'm Beau."/>
</h1>
<p class="text-xl py-4">
<Typewriter Name="@TypewriterConstants.Name.IntroComplete" Text="I'm a UK-based software engineer and I love building cool stuff."/>
</p>
<h2 class="text-2xl pt-16 font-semibold">
<Typewriter Text="A bit about me"/>
</h2>
<p class="text-xl py-4">
<Typewriter Text="I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users."/>
</p>
<p class="text-xl py-4">
<Typewriter Text="I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called un:hurd."/>
</p>
}
else
{
<h1 class="text-4xl">Hi, I'm Beau.</h1>
<p class="text-xl py-4">I'm a UK-based software engineer and I love building cool stuff.</p>
<h2 class="text-3xl pt-16 font-semibold">A bit about me</h2>
<p class="text-xl py-4">I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users.</p>
<p class="text-xl py-4">I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called <Anchor Href="https://unhurd.co.uk">un:hurd</Anchor>.</p>
}
@code {
private const string ComponentKey = "ComponentRendered_Home";
private bool hasPreviouslyRendered;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
var renderedBeforeAsString = await JSRuntime.InvokeAsync<string>("localStorage.getItem", ComponentKey);
var previousValue = hasPreviouslyRendered;
hasPreviouslyRendered = !string.IsNullOrEmpty(renderedBeforeAsString) && bool.Parse(renderedBeforeAsString);
if (!hasPreviouslyRendered)
{
await JSRuntime.InvokeVoidAsync("localStorage.setItem", ComponentKey, "true");
}
if (previousValue != hasPreviouslyRendered)
{
StateHasChanged();
}
}
}
}

View File

@@ -1,25 +0,0 @@
using BeauFindlay.Client;
using BeauFindlay.Client.Components.Typewriter;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
var builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
string apiBase;
if (builder.HostEnvironment.IsDevelopment())
{
apiBase = builder.Configuration["ApiBase"]
?? throw new ArgumentException("API base address not found in config.");
}
else
{
apiBase = builder.HostEnvironment.BaseAddress;
}
builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(apiBase) });
builder.Services.AddSingleton<ITypewriterNotificationService, TypewriterNotificationService>();
await builder.Build().RunAsync();

View File

@@ -1,41 +0,0 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:60918",
"sslPort": 44313
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "http://localhost:5218",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"applicationUrl": "https://localhost:7288;http://localhost:5218",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,17 +0,0 @@
@using System.Net.Http
@using System.Net.Http.Json
@using System.ComponentModel.DataAnnotations
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.AspNetCore.Components.WebAssembly.Http
@using Microsoft.JSInterop
@using BeauFindlay.Client
@using BeauFindlay.Client.Layout
@using BeauFindlay.Client.Components.Alert
@using BeauFindlay.Client.Components.Anchor
@using BeauFindlay.Client.Components.Typewriter
@using BeauFindlay.Client.Components.Button
@using BeauFindlay.Client.Components.LoadingSpinner
@using BeauFindlay.Shared.Contracts

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}

View File

@@ -1,5 +0,0 @@
{
"navigationFallback": {
"rewrite": "/index.html"
}
}

View File

@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./**/*.{razor,html,cshtml}"],
theme: {
extend: {
fontFamily: {
cascadia: ["Cascadia Code", "mono-space"]
}
},
},
plugins: [],
}

View File

@@ -1,10 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"ApiBase": "http://localhost:7071"
}

View File

@@ -1,45 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@font-face {
font-family: "Cascadia Code";
src: url("../fonts/CascadiaCode.woff2");
}
@keyframes blink {
from, to { opacity: 1 }
50% { opacity: 0 }
}
.blinking-cursor {
animation: blink 1s step-end infinite;
}
.fade-in {
animation: fadeInAnimation ease 3s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes fadeInAnimation {
from { opacity: 0; }
to { opacity: 1; }
}
.grecaptcha-badge {
visibility: hidden !important;
}
body::-webkit-scrollbar {
width: 14px;
}
body::-webkit-scrollbar-track {
background: white;
}
body::-webkit-scrollbar-thumb {
background-color: black;
border: 1px solid white;
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,58 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script id="cookieyes"
type="text/javascript"
src="https://cdn-cookieyes.com/client_data/a05e8ecc917e725a2226b46a/script.js"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-958BPT37HR"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-958BPT37HR');
</script>
<meta charset="utf-8"/>
<meta name="viewport"
content="width=device-width, initial-scale=1.0"/>
<meta property="description" content="I'm Beau. A software engineer, tech enthusianst and music lover.">
<meta property="author" content="Beau Findlay">
<meta property="og:title" content="Beau Findlay" />
<meta property="og:description" content="I'm Beau. A software engineer, tech enthusianst and music lover">
<meta property="og:type" content="website" />
<meta property="og:image" content="https://beaufindlay.com/images/logo.png" />
<meta property="og:url" content="https://beaufindlay.com" />
<title>Beau Findlay</title>
<base href="/"/>
<link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
<link rel="icon" type="image/png" href="images/logo.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet"
href="css/app.min.css"/>
<link rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
crossorigin="anonymous"
referrerpolicy="no-referrer"/>
</head>
<body class="bg-black font-cascadia text-slate-50 min-h-screen antialiased">
<div id="app"
class="h-full">
<div class="flex items-center justify-center text-2xl">
<p class="py-10">
Loading
beaufindlay.com<span class="blinking-cursor">|</span>
</p>
</div>
</div>
<script src="js/smoothScroll.js"></script>
<script src="_framework/blazor.webassembly.js"></script>
<script src="https://www.google.com/recaptcha/api.js?render=6LcvxZIpAAAAAOIP5L6kGngwDZRpwkTdMezPn06x"
async
defer></script>
<script src="js/recaptcha.js"></script>
</body>
</html>

View File

@@ -1,18 +0,0 @@
window.executeRecaptcha = function () {
return new Promise((resolve, reject) => {
grecaptcha.ready(function () {
grecaptcha
.execute("6LcvxZIpAAAAAOIP5L6kGngwDZRpwkTdMezPn06x", {
action: "submit",
})
.then(
function (token) {
resolve(token);
},
function (error) {
reject(error);
}
);
});
});
};

View File

@@ -1,10 +0,0 @@
function scrollToElement(id) {
const element = document.getElementById(id);
if (element instanceof HTMLElement) {
element.scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest"
});
}
}

View File

@@ -1,2 +0,0 @@
User-agent: *
Disallow:

View File

@@ -1,19 +0,0 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "images/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "images/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

View File

@@ -1,3 +0,0 @@
https://www.beaufindlay.com
https://www.beaufindlay.com/contact
https://www.beaufindlay.com/about

View File

@@ -1,7 +0,0 @@
namespace BeauFindlay.Shared.Abstractions;
public record Error(string Code, string Message)
{
public static readonly Error None = new(string.Empty, string.Empty);
public static readonly Error NullValue = new("Error.NullValue", "Null value was provided.");
}

View File

@@ -1,66 +0,0 @@
using System.Diagnostics.CodeAnalysis;
namespace BeauFindlay.Shared.Abstractions;
public class Result
{
protected Result(bool isSuccess, Error error)
{
switch (isSuccess)
{
case true when error != Error.None:
throw new InvalidOperationException("Successful result cannot contain an error.");
case false when error == Error.None:
throw new InvalidOperationException("Failed result must contain an error.");
default:
IsSuccess = isSuccess;
Error = error;
break;
}
}
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public Error Error { get; }
public static Result Success()
{
return new Result(true, Error.None);
}
public static Result Failure(Error error)
{
return new Result(false, error);
}
public static Result<TValue> Success<TValue>(TValue value)
{
return new Result<TValue>(value, true, Error.None);
}
public static Result<TValue> Failure<TValue>(Error error)
{
return new Result<TValue>(default, false, error);
}
protected static Result<TValue> Create<TValue>(TValue? value)
{
return value is not null ? Success(value) : Failure<TValue>(Error.NullValue);
}
}
public sealed class Result<TValue>(TValue? value, bool isSuccess, Error error)
: Result(isSuccess, error)
{
[NotNull]
public TValue Value => IsSuccess
? value!
: throw new InvalidOperationException("The value of a failure result can not be accessed.");
public static implicit operator Result<TValue>(TValue? value)
{
return Create(value);
}
}

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
</Project>

View File

@@ -1,19 +0,0 @@
using System.Net;
using Newtonsoft.Json;
namespace BeauFindlay.Shared.Contracts;
public sealed class ErrorResponse
{
[JsonProperty("code")]
public int Code { get; set; }
[JsonProperty("message")]
public string Message { get; set; } = string.Empty;
public static ErrorResponse Generic => new ErrorResponse
{
Code = (int)HttpStatusCode.BadRequest,
Message = "Opps... something went wrong."
};
}

View File

@@ -1,3 +0,0 @@
namespace BeauFindlay.Shared.Contracts;
public record SendContactEmailRequest(string Name, string FromEmail, string Message, string RecaptchaResponse);

18
src/Client/.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
src/Client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

30
src/Client/README.md Normal file
View File

@@ -0,0 +1,30 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
- Configure the top-level `parserOptions` property like this:
```js
export default {
// other rules...
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
project: ['./tsconfig.json', './tsconfig.node.json'],
tsconfigRootDir: __dirname,
},
}
```
- Replace `plugin:@typescript-eslint/recommended` to `plugin:@typescript-eslint/recommended-type-checked` or `plugin:@typescript-eslint/strict-type-checked`
- Optionally add `plugin:@typescript-eslint/stylistic-type-checked`
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and add `plugin:react/recommended` & `plugin:react/jsx-runtime` to the `extends` list

40
src/Client/index.html Normal file
View File

@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script id="cookieyes"
type="text/javascript"
src="https://cdn-cookieyes.com/client_data/a05e8ecc917e725a2226b46a/script.js"></script>
<script async src="https://www.googletagmanager.com/gtag/js?id=G-958BPT37HR"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-958BPT37HR');
</script>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
property="description"
content="I'm Beau. A software engineer, tech enthusianst and music lover."
/>
<meta property="author" content="Beau Findlay" />
<meta property="og:title" content="Beau Findlay" />
<meta
property="og:description"
content="I'm Beau. A software engineer, tech enthusianst and music lover"
/>
<meta property="og:type" content="website" />
<meta
property="og:image"
content="https://beaufindlay.com/logo.webp"
/>
<meta property="og:url" content="https://beaufindlay.com" />
<title>Beau Findlay</title>
<link rel="icon" type="image/svg+xml" href="/logo.webp" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4372
src/Client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
src/Client/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@headlessui/react": "^1.7.19",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.1.0",
"react-router-dom": "^6.23.0"
},
"devDependencies": {
"@types/node": "^20.12.7",
"@types/react": "^18.2.66",
"@types/react-dom": "^18.2.22",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.6",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
src/Client/public/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow:

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,137 @@
import { Tab } from "@headlessui/react";
import { Fragment, ReactNode } from "react";
import { SiMicrosoftazure, SiReact } from "react-icons/si";
import buildClassNames from "../helpers/cssClassFormatter";
import AnchorLink from "./AnchorLink";
import Subtitle from "./Subtitle";
import Text from "./Text";
interface AboutTab {
tabName: string;
title: ReactNode;
subtitle: string;
content: ReactNode[];
}
const tabs: AboutTab[] = [
{
tabName: "Front-end",
title: (
<Subtitle>
Front-end <SiReact className="ml-2" />
</Subtitle>
),
subtitle: "React + TypeScript",
content: [
<Text>
This app was originally made using{" "}
<AnchorLink href="https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor">
.NET Blazor WASM
</AnchorLink>{" "}
however I recently decided to start learning{" "}
<AnchorLink href="https://react.dev/">React</AnchorLink> and saw this as
a good oppurtunity to solidify some knowledge by re-writing this app
from the ground up using React with{" "}
<AnchorLink href="https://www.typescriptlang.org/">
TypeScript
</AnchorLink>
.
</Text>,
<Text>
Overall I've found building front-end apps far more enjoyable using
React & TypeScript dispite the steep learning-curve coming from a purely
.NET & C# background. Both the developer experience and performance of
the app have improved dramatically after switching front-end
technologies.
</Text>,
<Text>
This app is styled using a cool CSS framework called{" "}
<AnchorLink href="https://tailwindcss.com/">TailwindCSS</AnchorLink>.{" "}
<AnchorLink href="https://postcss.org/">PostCSS</AnchorLink> is used
alongside Tailwind to generate a lightweight stylesheet based only on
the parts of the framework that are used, as oppose to including a
everything the framework offers.
</Text>,
],
},
{
tabName: "Hosting",
title: (
<Subtitle>
Hosting <SiMicrosoftazure className="ml-2" />
</Subtitle>
),
subtitle: "Microsoft Azure Static Web App",
content: [
<Text>
The goal of this project was to learn some new technologies and host the
app as cheaply as possible. With this in mind I decided to go with a{" "}
<AnchorLink href="https://azure.microsoft.com/en-gb/products/app-service/static">
Static Web App
</AnchorLink>{" "}
hosted on Microsoft Azure. Static Web Apps offer global distribution of
static assets (the Blazor Webassembly app in this case) and offer
integrated hosting for Azure Function App APIs.
</Text>,
<Text>
Another cool feature of Static Web Apps is Azure's integration with
GitHub actions to deploy both the client and server simultaneously and
provide automatically deployed staging environments for pull-requests
opened to the main branch. This made testing deployed changes much
easier and cheaper than deploying an isolated testing/GA environment
before releasing to the live version of the app.
</Text>,
<Text>
Using Static Web Apps on Azure has meant that I have been able to build,
deploy and serve this site completely free (with the exception of the
domain name). The next thing on the roadmap is building a simple blog
using an{" "}
<AnchorLink href="https://azure.microsoft.com/en-gb/products/azure-sql/database">
Azure SQL database
</AnchorLink>{" "}
where I'll document the full process of writing and deploying this app
so check back again soon.
</Text>,
],
},
];
export default function AboutTabs() {
return (
<Tab.Group as="div" className="mt-4">
<div className="-mx-4 flex overflow-x-auto sm:mx-0">
<div className="flex-auto border-b border-gray-200 px-4 sm:px-0">
<Tab.List className="-mb-px flex space-x-8">
{tabs.map((tab) => (
<Tab
key={tab.tabName}
className={({ selected }) =>
buildClassNames(
selected
? "border-gray-300 text-gray-200"
: "border-transparent hover:border-gray-300 hover:text-gray-200",
"whitespace-nowrap border-b-4 py-6 text-sm font-medium"
)
}
>
{tab.tabName}
</Tab>
))}
</Tab.List>
</div>
</div>
<Tab.Panels as={Fragment}>
{tabs.map((tab) => (
<Tab.Panel key={tab.tabName} className="pt-10">
{tab.title}
<p className="font-bold text-lg my-4">Tech: {tab.subtitle}</p>
{tab.content.map((content, index) => (
<Fragment key={index}>{content}</Fragment>
))}
</Tab.Panel>
))}
</Tab.Panels>
</Tab.Group>
);
}

View File

@@ -0,0 +1,24 @@
import buildClassNames from "../helpers/cssClassFormatter";
interface Props {
children: string;
href?: string;
target?: "_blank" | "";
className?: string | null;
}
export default function AnchorLink({
children,
href,
target,
className,
}: Props) {
const defaultStyles = "underline underline-offset-2 hover:underline-offset-4";
const styles = buildClassNames(className ? className : "", defaultStyles);
return (
<a href={href} target={target} className={styles}>
{children}
</a>
);
}

View File

@@ -0,0 +1,19 @@
import { ReactNode } from "react";
interface Props {
type: "submit" | "button";
children: ReactNode;
onClick?: () => void;
}
export default function Button({ type, children, onClick }: Props) {
return (
<button
type={type}
onClick={onClick}
className="flex items-center justify-center border-0 ring-1 ring-inset ring-gray-300 bg-black px-3.5 py-2.5 text-sm font-semibold text-white shadow hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600 disabled:bg-gray-800 disabled:cursor-progress"
>
{children}
</button>
);
}

View File

@@ -0,0 +1,16 @@
import { FaRegPaperPlane } from "react-icons/fa6";
import Text from "../components/Text";
export default function ContactMe() {
return (
<div className="mb-10 mt-12 text-center">
<Text>If you think I can help with your project...</Text>
<a
href="mailto:me@beaufindlay.com"
className="inline-flex items-center border-0 ring-1 ring-inset ring-gray-300 bg-black px-3.5 py-2.5 mt-2 text-sm font-semibold text-white shadow hover:bg-gray-800 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-600"
>
Get in touch <FaRegPaperPlane className="ml-2" />
</a>
</div>
);
}

View File

@@ -0,0 +1,18 @@
import SocialIcons from "./SocialIcons";
export default function Footer() {
const currentYear = new Date().getFullYear();
return (
<footer className="mt-auto">
<div className="mx-auto py-8">
<div className="md:flex md:items-center md:justify-between">
<SocialIcons />
<p className="mt-4 text-xs leading-5 text-gray-100 md:order-1 md:mt-0">
&copy; {currentYear} Beau Findlay. All rights reserved.
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,12 @@
interface Props {
htmlFor: string;
children: string;
}
export default function Label({ htmlFor, children }: Props) {
return (
<label htmlFor={htmlFor} className="block font-semibold leading-6">
{children}
</label>
);
}

View File

@@ -0,0 +1,15 @@
import { ReactElement } from "react";
import buildClassNames from "../helpers/cssClassFormatter";
import { ListItemProps } from "./ListItem";
interface Props {
className?: string | null;
children: ReactElement<ListItemProps> | Array<ReactElement<ListItemProps>>;
}
export default function List({ className, children }: Props) {
const defaultStyles = "list-disc pl-8 space-y-2";
const styles = buildClassNames(className ? className : "", defaultStyles);
return <ul className={styles}>{children}</ul>;
}

View File

@@ -0,0 +1,9 @@
import { ReactNode } from "react";
export interface ListItemProps {
children: ReactNode;
}
export default function ListItem({ children }: ListItemProps) {
return <li>{children}</li>;
}

View File

@@ -0,0 +1,13 @@
import LoadingSpinner from "./LoadingSpinner";
import logo from "../assets/logo.webp";
export default function Loading() {
return (
<div className="bg-black font-mono text-slate-50 antialiased px-6 lg:px-8">
<div className="flex flex-col min-h-screen mx-auto max-w-7xl items-center justify-center fade-in">
<img className="h-20 w-auto" src={logo} alt="Logo" />
<LoadingSpinner />
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
export default function LoadingSpinner() {
return (
<div role="status">
<svg
aria-hidden="true"
className="w-8 h-8 text-gray-500 animate-spin fill-white"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
fill="currentColor"
/>
<path
d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
fill="currentFill"
/>
</svg>
<span className="sr-only">Loading...</span>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import { Dialog, Popover } from "@headlessui/react";
import { useState } from "react";
import { FaBars, FaXmark } from "react-icons/fa6";
import { Link } from "react-router-dom";
import logo from "../assets/logo.webp";
import SocialIcons from "./SocialIcons";
import NavLink from "./NavLink";
export default function NavBar() {
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
return (
<header className="pt-6">
<nav
className="mx-auto flex max-w-7xl items-center justify-between"
aria-label="Global"
>
<div className="flex lg:flex-1">
<Link to="/" className="-m-1.5 p-1.5">
<span className="sr-only">Beau Findlay</span>
<img className="h-16 w-auto" src={logo} alt="Logo" />
</Link>
</div>
<div className="flex lg:hidden">
<button
type="button"
className="-m-2.5 inline-flex items-center justify-center rounded-md p-2.5"
onClick={() => setMobileMenuOpen(true)}
>
<span className="sr-only">Open main menu</span>
<FaBars className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<Popover.Group className="hidden lg:flex lg:gap-x-12">
<NavLink to="/work">Work</NavLink>
<NavLink to="/about">This App</NavLink>
</Popover.Group>
</nav>
<Dialog
as="div"
className="lg:hidden"
open={mobileMenuOpen}
onClose={setMobileMenuOpen}
>
<div className="fixed inset-0 z-10" />
<Dialog.Panel className="fixed inset-y-0 right-0 z-10 bg-black w-full overflow-y-auto px-6 py-6 sm:max-w-sm sm:ring-1 sm:ring-gray-900/10 text-white sm:border-l-2">
<div className="flex items-center justify-between">
<Link
to="/"
className="-m-1.5 p-1.5"
onClick={() => setMobileMenuOpen(false)}
>
<span className="sr-only">Beau Findlay</span>
<img className="h-16 w-auto" src={logo} alt="Logo" />
</Link>
<button
type="button"
className="-m-2.5 rounded-md p-2.5"
onClick={() => setMobileMenuOpen(false)}
>
<span className="sr-only">Close menu</span>
<FaXmark className="h-6 w-6" aria-hidden="true" />
</button>
</div>
<div className="mt-6 flow-root">
<div className="-my-6 divide-y divide-gray-200/10">
<div className="space-y-2 py-6">
<NavLink
to="/work"
className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7"
onClick={() => setMobileMenuOpen(false)}
>
Work
</NavLink>
<NavLink
to="/about"
className="-mx-3 block rounded-lg px-3 py-2 text-base font-semibold leading-7"
onClick={() => setMobileMenuOpen(false)}
>
This App
</NavLink>
</div>
<div className="flex justify-center items-center py-8">
<SocialIcons size={24} />
</div>
</div>
</div>
</Dialog.Panel>
</Dialog>
</header>
);
}

View File

@@ -0,0 +1,25 @@
import { NavLink as ReactNavLink } from "react-router-dom";
interface Props {
children: string;
to: string;
className?: string | null;
onClick?: () => void;
}
export default function NavLink({ children, to, className, onClick }: Props) {
const defaultStyles = "text-base font-semibold leading-6 hover:text-gray-300";
const styles = className ? className : defaultStyles;
return (
<ReactNavLink
to={to}
onClick={onClick}
className={({ isActive }) =>
isActive ? `${styles} underline underline-offset-4` : styles
}
>
{children}
</ReactNavLink>
);
}

View File

@@ -0,0 +1,40 @@
import { FaEnvelope, FaGithub, FaLinkedin } from "react-icons/fa6";
interface Props {
size?: number;
}
export default function SocialIcons({ size = 20 }: Props) {
const socialIcons = [
{
name: "GitHub",
href: "https://github.com/bdfin",
icon: <FaGithub size={size} />,
},
{
name: "LinkedIn",
href: "https://www.linkedin.com/in/beau-findlay/",
icon: <FaLinkedin size={size} />,
},
{
name: "Email",
href: "mailto:me@beaufindlay.com",
icon: <FaEnvelope size={size} />,
},
];
return (
<div className="flex space-x-6 md:order-2">
{socialIcons.map((socialIcon) => (
<a
key={socialIcon.name}
href={socialIcon.href}
className="text-gray-100 hover:text-gray-300"
>
<span className="sr-only">{socialIcon.name}</span>
{socialIcon.icon}
</a>
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More