Merge pull request #16 from bdfin/blazor-refactor
Some checks failed
Azure Static Web Apps CI/CD / Build and Deploy Job (push) Failing after 57s
Azure Static Web Apps CI/CD / Close Pull Request Job (push) Has been skipped

Re-writes the app in Blazor server
This commit is contained in:
2026-02-01 12:01:33 +00:00
committed by GitHub
102 changed files with 3494 additions and 5417 deletions

25
.dockerignore Normal file
View File

@@ -0,0 +1,25 @@
**/.dockerignore
**/.env
**/.git
**/.gitignore
**/.project
**/.settings
**/.toolstarget
**/.vs
**/.vscode
**/.idea
**/*.*proj.user
**/*.dbmdl
**/*.jfm
**/azds.yaml
**/bin
**/charts
**/docker-compose*
**/Dockerfile*
**/node_modules
**/npm-debug.log
**/obj
**/secrets.dev.yaml
**/values.dev.yaml
LICENSE
README.md

64
.gitignore vendored Normal file
View File

@@ -0,0 +1,64 @@
## .NET
bin/
obj/
*.user
*.suo
*.userosscache
*.sln.docstates
.vs/
.vscode/
## Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
## Visual Studio cache/options directory
.vs/
.vscode/
## JetBrains Rider
.idea/
*.sln.iml
## User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
## ASP.NET Scaffolding
ScaffoldingReadMe.txt
## Configuration files (ignore environment-specific settings)
**/appsettings.Development.json
**/appsettings.*.json
!**/appsettings.json
## Publish profiles
*.pubxml
*.azurePubxml
## NuGet Packages
*.nupkg
*.snupkg
**/packages/*
## Node.js (keeping for now, will remove after React cleanup)
node_modules/
package-lock.json
## OS files
.DS_Store
Thumbs.db

15
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,15 @@
# Default ignored files
/shelf/
/workspace.xml
# Rider ignored files
/.idea.my-portfolio.iml
/modules.xml
/projectSettingsUpdater.xml
/contentModel.xml
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
# Editor-based HTTP Client requests
/httpRequests/

4
.idea/encodings.xml generated Normal file
View File

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

8
.idea/indexLayout.xml generated Normal file
View File

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

6
.idea/vcs.xml generated Normal file
View File

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

29
Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
WORKDIR /app
EXPOSE 5000
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["src/BlazorApp/BlazorApp.csproj", "src/BlazorApp/"]
RUN dotnet restore "src/BlazorApp/BlazorApp.csproj"
COPY . .
WORKDIR "/src/src/BlazorApp"
RUN dotnet build "./BlazorApp.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./BlazorApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
# Install curl for health checks
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
COPY --from=publish /app/publish .
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD curl -f http://localhost:5000/health || exit 1
ENTRYPOINT ["dotnet", "BlazorApp.dll"]

766
migration.md Normal file
View File

@@ -0,0 +1,766 @@
# Blazor SSR Migration Plan
## Project Overview
Migrating a React/TypeScript portfolio website to a **minimal, dependency-free** .NET Blazor application with static server-side rendering.
**Current Stack:**
- React 18.2 + TypeScript
- Vite build tool
- Tailwind CSS (to be replaced with vanilla CSS)
- React Router DOM
- Headless UI components (Dialog/Tabs - to be replaced with vanilla HTML/CSS/JS)
- React Icons (to be replaced with SVG icons)
- Azure Static Web Apps hosting
**Target Stack:**
- .NET 10 Blazor with Static SSR (no WebSockets/SignalR)
- C# Razor components (server-rendered only)
- **Vanilla CSS only** (no frameworks, no preprocessors)
- Blazor Router (built-in)
- **No external component libraries**
- **No icon dependencies** (inline SVG)
- Azure App Service or Azure Container Apps hosting
**Migration Philosophy:**
- Zero external dependencies beyond .NET runtime
- Simple, maintainable vanilla CSS
- Static server-side rendering (no interactivity circuits)
- Modern CSS features (Grid, Flexbox, CSS Variables, Container Queries)
- Native HTML elements with custom styling
- **Zero JavaScript** - pure CSS solutions for all interactivity (checkbox hack, :target pseudo-class)
---
## Phase 1: Project Setup
### 1.1 .NET Project Initialization
- [ ] Install .NET 10 SDK
- [ ] Create new Blazor Web App project with SSR only: `dotnet new blazor -o src/BlazorApp -int None`
- Note: `-int None` disables interactivity (no SignalR/WebSockets)
- [ ] Verify project builds: `dotnet build`
- [ ] Review generated files: `Program.cs`, `App.razor`, `Components/_Imports.razor`, `appsettings.json`
- [ ] Confirm `Program.cs` does NOT include `AddInteractiveServerComponents()` or SignalR setup
### 1.2 Solution Structure
- [ ] Create solution file: `dotnet new sln -n my-portfolio`
- [ ] Add project to solution: `dotnet sln add src/BlazorApp/BlazorApp.csproj`
- [ ] Configure project properties (nullable, implicit usings)
- [ ] Ensure render modes are set to static SSR only
### 1.3 Git Configuration
- [ ] Update `.gitignore` for .NET (bin/, obj/, .vs/)
- [ ] Archive React project (move to `archive/react-version/` or create `pre-blazor-migration` tag)
- [ ] Commit initial Blazor project structure
---
## Phase 2: CSS Architecture & Design System
### 2.1 CSS Variables Setup
- [ ] Create `wwwroot/css/variables.css` with design tokens:
- Colors (black: #000, white: #fff, gray shades)
- Spacing scale (0.25rem, 0.5rem, 1rem, 1.5rem, 2rem, etc.)
- Typography (font-family: monospace, font-sizes, line-heights)
- Breakpoints (mobile-first: 640px, 768px, 1024px, 1280px)
- Z-index scale
- Border radius values
- Transition durations
### 2.2 Base Styles
- [ ] Create `wwwroot/css/reset.css` with modern CSS reset
- [ ] Create `wwwroot/css/base.css`:
- Body styles (background: black, color: slate-50, font: monospace)
- Typography defaults
- Link styles
- Focus styles for accessibility
- Custom scrollbar styles
- Selection styles
### 2.3 Layout Utilities
- [ ] Create `wwwroot/css/layout.css`:
- Container classes (max-width, centering)
- Flexbox utilities (flex, flex-col, items-center, justify-between, gap, etc.)
- Grid utilities (grid, grid-cols)
- Spacing utilities (padding, margin classes)
- Responsive utilities
### 2.4 Component Styles
- [ ] Create `wwwroot/css/components.css`:
- Button styles (primary, secondary, hover, focus, disabled states)
- Input/textarea styles
- Card styles
- Navigation styles
- Footer styles
- Link styles
- [ ] Create `wwwroot/css/animations.css`:
- Fade-in animation
- Hover transitions
- Loading spinner animation
### 2.5 Compile CSS
- [ ] Create `wwwroot/css/app.css` that imports all CSS files
- [ ] Reference in `index.html`: `<link href="css/app.css" rel="stylesheet" />`
- [ ] Test CSS loads correctly
---
## Phase 3: SVG Icon System
### 3.1 Extract Icons
- [ ] Identify all react-icons used:
- FaBars (hamburger menu)
- FaXmark (close X)
- FaGithub
- FaLinkedin
- FaEnvelope
- FaDatabase
- FaDocker
- FaReact
- SiCsharp
- SiMicrosoftazure
- SiBlazor
- [ ] Download SVG paths from icon sources (FontAwesome, Simple Icons)
- [ ] Create reusable Icon component: `Components/Icon.razor`
### 3.2 Icon Component
- [ ] Implement `Icon.razor` with parameters:
- `Name` (string): icon identifier
- `Size` (int, default 24): icon size in pixels
- `CssClass` (string): additional CSS classes
- [ ] Store SVG paths in C# dictionary or switch statement
- [ ] Test all icons render correctly
---
## Phase 4: Core Application Structure
### 4.1 Routing Setup
- [ ] Configure routes in `App.razor`:
- `/` → Home page
- `/work` → Work page
- `/about` → About page
- `NotFound` → 404/Error page
- [ ] Test routing and navigation
### 4.2 Layout Component
- [ ] Create `Shared/MainLayout.razor`
- [ ] Implement structure:
- Header with NavBar
- Main content area with `@Body`
- Footer
- [ ] Apply CSS classes (container, flex layout, min-height)
- [ ] Add fade-in animation class
- [ ] Test layout renders correctly
### 4.3 Navigation Component
- [ ] Create `Shared/NavBar.razor`
- [ ] Implement desktop navigation:
- Logo image
- Navigation links (Home, Work, About)
- [ ] Implement mobile navigation:
- Hamburger button (toggle mobile menu)
- Mobile menu overlay with links
- Close button
- Social icons in mobile menu
- [ ] Use Blazor's built-in `NavLink` component with custom styling
- [ ] Implement mobile menu state with C# boolean property
- [ ] Style with pure CSS (no Headless UI)
### 4.4 Footer Component
- [ ] Create `Shared/Footer.razor`
- [ ] Port footer content
- [ ] Apply styling
---
## Phase 5: Page Components
### 5.1 Home Page
- [ ] Create `Pages/Home.razor` with `@page "/"`
- [ ] Port content from `HomePage.tsx`
- [ ] Reference child components (Title, Text, TechIcons)
- [ ] Test rendering
### 5.2 Work Page
- [ ] Create `Pages/Work.razor` with `@page "/work"`
- [ ] Port content from `WorkPage.tsx`
- [ ] Test rendering
### 5.3 About Page
- [ ] Create `Pages/About.razor` with `@page "/about"`
- [ ] Port content from `AboutPage.tsx`
- [ ] Test rendering
### 5.4 Error/404 Page
- [ ] Create `Pages/NotFound.razor` with `@page "/404"`
- [ ] Port content from `ErrorPage.tsx`
- [ ] Configure as NotFound in router
- [ ] Test 404 handling
---
## Phase 6: Reusable UI Components
### 6.1 Typography Components (7)
- [ ] `Components/Title.razor` - H1 heading
- [ ] `Components/Subtitle.razor` - H2 heading
- [ ] `Components/Text.razor` - Paragraph with margin
- [ ] `Components/Label.razor` - Form label
- [ ] `Components/List.razor` - UL wrapper
- [ ] `Components/ListItem.razor` - LI element
- [ ] `Components/AnchorLink.razor` - External link with styling
### 6.2 Form Components (3)
- [ ] `Components/Button.razor` - Button with hover/focus states
- [ ] `Components/TextInput.razor` - Input field with label
- [ ] `Components/TextAreaInput.razor` - Textarea with label
### 6.3 Display Components (5)
- [ ] `Components/TechIcons.razor` - Tech stack grid with icons
- [ ] `Components/SocialIcons.razor` - Social media links with icons
- [ ] `Components/WorkTimeline.razor` - Work experience timeline
- [ ] `Components/Loading.razor` - Loading state wrapper
- [ ] `Components/LoadingSpinner.razor` - Spinner animation
### 6.4 Feature Components (2)
- [ ] `Components/ContactMe.razor` - Contact form
- [ ] `Components/AboutTabs.razor` - Tab interface (vanilla CSS/JS)
---
## Phase 7: Interactive Components (Pure CSS - No JavaScript)
### 7.1 Mobile Menu (CSS Checkbox Hack)
- [ ] Implement in `NavBar.razor`:
- Add hidden checkbox: `<input type="checkbox" id="mobile-menu-toggle" class="menu-toggle" />`
- Add label for hamburger: `<label for="mobile-menu-toggle">☰</label>`
- Add label for close button inside menu: `<label for="mobile-menu-toggle">✕</label>`
- Render menu panel as sibling to checkbox
- [ ] Style with CSS:
- Hide checkbox: `.menu-toggle { display: none; }`
- Show/hide menu based on checkbox state: `.menu-toggle:checked ~ .mobile-menu { ... }`
- Show/hide overlay: `.menu-toggle:checked ~ .overlay { ... }`
- Slide-in animation using `transform: translateX()`
- Transparent overlay with backdrop-filter blur
- Z-index layering for proper stacking
- [ ] Test keyboard navigation (checkbox is focusable)
### 7.2 Tabs Component (CSS :target Pseudo-Class or Radio Buttons)
- [ ] **Option A - Radio Button Approach (Recommended):**
- Create `AboutTabs.razor`:
- Hidden radio inputs: `<input type="radio" name="tabs" id="tab1" checked />`
- Label buttons: `<label for="tab1">Tab 1</label>`
- Tab panels as siblings to inputs
- Style with CSS:
- Hide radio buttons: `input[type="radio"] { display: none; }`
- Active tab styling: `#tab1:checked ~ .tabs-labels label[for="tab1"] { ... }`
- Show panel: `#tab1:checked ~ .tab-panels .panel1 { display: block; }`
- Hide other panels by default
- [ ] **Option B - :target Pseudo-Class:**
- Use anchor links: `<a href="#tab1">Tab 1</a>`
- Panel IDs: `<div id="tab1" class="tab-panel">...</div>`
- Style: `.tab-panel:target { display: block; }`
- Note: Changes URL hash
- [ ] Choose approach and implement
- [ ] Add smooth transitions with CSS
### 7.3 Form Handling (Traditional POST)
- [ ] Implement form submission in `ContactMe.razor`
- [ ] Use standard HTML `<form>` with POST action
- [ ] Server-side endpoint to handle form submission
- [ ] Redirect after post pattern (PRG)
- [ ] Add HTML5 validation attributes
- [ ] Style validation states with CSS (`:invalid`, `:valid`)
---
## Phase 8: Tailwind CSS to Vanilla CSS Conversion
### 8.1 Analyze Tailwind Usage
- [ ] Document all Tailwind classes used in current app:
- Layout: `flex`, `flex-col`, `items-center`, `justify-between`, `max-w-7xl`, etc.
- Spacing: `px-6`, `py-8`, `mt-4`, `mb-6`, `space-x-6`, etc.
- Typography: `text-sm`, `text-xl`, `font-semibold`, `font-mono`, etc.
- Colors: `bg-black`, `text-white`, `text-gray-200`, `ring-gray-300`, etc.
- Responsive: `lg:flex`, `md:order-2`, `sm:max-w-sm`, etc.
- Effects: `hover:bg-gray-800`, `focus-visible:outline`, etc.
### 8.2 Create CSS Equivalents
- [ ] Create utility classes in `layout.css`:
```css
.flex { display: flex; }
.flex-col { flex-direction: column; }
.items-center { align-items: center; }
.justify-between { justify-content: space-between; }
.max-w-7xl { max-width: 80rem; margin: 0 auto; }
.container { max-width: 1280px; margin: 0 auto; padding: 0 1.5rem; }
```
- [ ] Create spacing utilities:
```css
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
.mt-4 { margin-top: 1rem; }
/* etc. */
```
- [ ] Create responsive utilities using media queries:
```css
@media (min-width: 1024px) {
.lg\:flex { display: flex; }
}
```
- [ ] Alternative: Use component-specific classes instead of utilities
### 8.3 Choose Approach
- [ ] **Option A (Recommended):** Component-specific CSS classes
- More maintainable for small projects
- Better for this portfolio site (20 components)
- Example: `.nav-bar {}`, `.nav-bar__logo {}`, `.nav-bar__menu {}`
- [ ] **Option B:** Minimal utility classes
- Create only the most-used utilities (flex, grid, spacing)
- Combine with component classes
- [ ] Document chosen approach in README
---
## Phase 9: SEO & Meta Tags
### 9.1 HTML Head Configuration
- [ ] Update `wwwroot/index.html` with meta tags:
- Charset, viewport
- Description, author
- Open Graph tags (og:title, og:description, og:image, og:url)
- Favicon reference
- [ ] Ensure all tags from React version are migrated
- [ ] Test with social media preview tools
### 9.2 Analytics Integration
- [ ] Add Google Analytics script to `index.html`
- [ ] Add CookieYes consent script to `index.html`
- [ ] Test analytics on navigation (use `NavigationManager.LocationChanged` if needed)
- [ ] Verify GDPR compliance
---
## Phase 10: Build & Optimization
### 10.1 Build Configuration
- [ ] Configure `.csproj` for optimizations:
```xml
<PropertyGroup>
<PublishReadyToRun>true</PublishReadyToRun>
<PublishReadyToRunShowWarnings>true</PublishReadyToRunShowWarnings>
</PropertyGroup>
```
- [ ] Test production build: `dotnet publish -c Release`
- [ ] Analyze output in `bin/Release/net10.0/publish/`
- [ ] Verify CSS is minimal and unminified (easy to debug)
- [ ] Verify NO JavaScript files present
### 10.2 CSS Optimization
- [ ] Remove unused CSS
- [ ] Combine CSS files if beneficial
- [ ] Consider minification (optional - Azure handles this)
- [ ] Test CSS loads and applies correctly
- [ ] Verify CSS-only menu and tabs work in all browsers
### 10.3 Image Optimization
- [ ] Verify `logo.webp` is optimized
- [ ] Add width/height attributes to prevent layout shift
- [ ] Test image loading
### 10.4 Zero JavaScript Verification
- [ ] Confirm no `.js` files in `wwwroot/js/`
- [ ] Confirm no `<script>` tags in layouts/pages
- [ ] Test site functions without JavaScript enabled in browser
---
## Phase 11: Deployment Configuration
### 11.1 Azure App Service Configuration
- [ ] Create Azure App Service (Linux or Windows)
- [ ] Configure environment variables in Azure portal:
- `ASPNETCORE_ENVIRONMENT=Production`
- [ ] Enable HTTPS only
- [ ] Configure health check endpoint
- [ ] Set up Application Insights (optional)
### 11.2 GitHub Actions Workflow
- [ ] Create/update `.github/workflows/azure-deploy.yml`:
```yaml
name: Deploy to Azure App Service
on:
push:
branches: [ main ]
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '10.0.x'
- name: Build
run: dotnet build src/BlazorApp/BlazorApp.csproj -c Release
- name: Publish
run: dotnet publish src/BlazorApp/BlazorApp.csproj -c Release -o ${{env.DOTNET_ROOT}}/myapp
- name: Deploy to Azure Web App
uses: azure/webapps-deploy@v2
with:
app-name: 'your-app-name'
publish-profile: ${{ secrets.AZURE_WEBAPP_PUBLISH_PROFILE }}
package: ${{env.DOTNET_ROOT}}/myapp
```
- [ ] Set up Azure publish profile in GitHub secrets
- [ ] Test workflow on branch
### 11.3 Local Development Setup
- [ ] Document commands:
- Run: `dotnet watch` (hot reload enabled)
- Build: `dotnet build`
- Publish: `dotnet publish -c Release`
- [ ] Test hot reload functionality
- [ ] Configure HTTPS for local dev (trust cert)
- [ ] Verify no SignalR scripts loaded (check browser network tab)
- [ ] Test CSS-only interactivity (menu, tabs) during development
---
## Phase 12: Testing & Quality Assurance
### 12.1 Visual Testing
- [ ] Compare React site vs Blazor site side-by-side
- [ ] Test all pages render identically
- [ ] Test all components display correctly
- [ ] Test responsive design:
- Mobile (320px, 375px, 414px)
- Tablet (768px, 1024px)
- Desktop (1280px, 1920px)
- [ ] Test cross-browser:
- Chrome/Edge (Chromium)
- Firefox
- Safari (if available)
### 12.2 Functional Testing
- [ ] Test navigation between pages (full page reloads)
- [ ] Test mobile menu open/close (CSS checkbox)
- [ ] Test tab switching in About page (CSS radio/target)
- [ ] Test all external links
- [ ] Test form submission with POST/redirect
- [ ] Test 404 page navigation
- [ ] Test with JavaScript disabled in browser
- [ ] Verify no JavaScript errors (should be none)
### 12.3 Accessibility Testing
- [ ] Test keyboard navigation (Tab, Enter, Space for checkboxes/radios)
- [ ] Test screen reader compatibility (aria-labels, proper label associations)
- [ ] Test focus indicators (especially on hidden checkbox/radio controls)
- [ ] Test color contrast (black/white theme)
- [ ] Verify checkbox and radio button labels are properly associated
- [ ] Run Lighthouse accessibility audit
### 12.4 Performance Testing
- [ ] Test server response times for page requests
- [ ] Test First Contentful Paint (FCP)
- [ ] Test Time to Interactive (TTI)
- [ ] Test with throttled network (3G)
- [ ] Verify no WebSocket connections in network tab
- [ ] Verify no JavaScript downloads
- [ ] Run Lighthouse performance audit
- [ ] Target: FCP < 1s, TTI < 1.5s (server-rendered, zero JavaScript, instant interactive)
### 12.5 SEO Testing
- [ ] Test meta tags with social media debuggers:
- Facebook Sharing Debugger
- Twitter Card Validator
- LinkedIn Post Inspector
- [ ] Test robots.txt accessibility
- [ ] Test favicon in browser tabs
- [ ] Run Lighthouse SEO audit
---
## Phase 13: Documentation & Cleanup
### 13.1 Update Documentation
- [ ] Update README.md:
```markdown
# beaufindlay.com
My personal portfolio site built with .NET Blazor WebAssembly.
## Tech Stack
- .NET 10 Blazor SSR (static server-side rendering)
- Pure CSS only (zero JavaScript)
- Hosted on Azure App Service
## Development
- Run: `dotnet watch`
- Build: `dotnet build`
- Publish: `dotnet publish -c Release`
## Features
- Zero external dependencies (no npm, no frameworks, no JavaScript)
- Static server-side rendering (no WebSockets)
- Pure CSS interactivity (checkbox hack for menu, radio buttons for tabs)
- Mobile-first responsive design
- Accessible and SEO-optimized
- Works with JavaScript disabled
```
- [ ] Document CSS architecture in comments
- [ ] Add migration notes (lessons learned, gotchas)
### 13.2 Clean Up React Files
- [ ] Archive React project:
- Option A: Move to `archive/react-version/`
- Option B: Create git tag `pre-blazor-migration` and delete
- Option C: Keep in separate branch
- [ ] Remove Node.js files from root:
- Delete `src/Client/package.json`
- Delete `src/Client/package-lock.json`
- Delete `src/Client/node_modules/`
- Delete `src/Client/tsconfig*.json`
- Delete `src/Client/vite.config.ts`
- Delete `src/Client/tailwind.config.js`
- Delete `src/Client/postcss.config.js`
- Delete `src/Client/.eslintrc.cjs`
- [ ] Update `.gitignore` (remove Node.js entries)
### 13.3 Code Quality
- [ ] Run `dotnet format` on all files
- [ ] Add XML documentation comments to public components
- [ ] Ensure consistent naming conventions
- [ ] Remove unused using statements
- [ ] Remove commented-out code
---
## Phase 14: Production Deployment
### 14.1 Pre-Deployment Checklist
- [ ] All components migrated and tested ✓
- [ ] All routes working ✓
- [ ] Mobile menu functional ✓
- [ ] Tabs functional ✓
- [ ] Analytics configured ✓
- [ ] SEO tags verified ✓
- [ ] Performance acceptable ✓
- [ ] No console errors ✓
- [ ] Cross-browser tested ✓
### 14.2 Deploy to Production
- [ ] Merge `blazor-refactor` branch to `main`
- [ ] Monitor GitHub Actions workflow
- [ ] Verify deployment succeeds
- [ ] Test production site at beaufindlay.com
### 14.3 Post-Deployment
- [ ] Verify site loads correctly in production
- [ ] Test all functionality in production
- [ ] Verify analytics tracking
- [ ] Monitor for errors (first 24-48 hours)
- [ ] Check browser console for any warnings
- [ ] Verify SSL certificate
- [ ] Test social media sharing previews
---
## Component Migration Checklist
### Pages (5)
- [ ] Layout (MainLayout.razor)
- [ ] Home (Pages/Home.razor)
- [ ] Work (Pages/Work.razor)
- [ ] About (Pages/About.razor)
- [ ] Error/404 (Pages/NotFound.razor)
### Typography Components (7)
- [ ] Title.razor
- [ ] Subtitle.razor
- [ ] Text.razor
- [ ] Label.razor
- [ ] List.razor
- [ ] ListItem.razor
- [ ] AnchorLink.razor
### Form Components (3)
- [ ] Button.razor
- [ ] TextInput.razor
- [ ] TextAreaInput.razor
### Display Components (5)
- [ ] TechIcons.razor
- [ ] SocialIcons.razor
- [ ] WorkTimeline.razor
- [ ] Loading.razor
- [ ] LoadingSpinner.razor
### Feature Components (3)
- [ ] NavBar.razor (in Shared/)
- [ ] Footer.razor (in Shared/)
- [ ] ContactMe.razor
- [ ] AboutTabs.razor
### Shared/Infrastructure (2)
- [ ] Icon.razor (new - SVG icon system)
- [ ] CssHelper.cs (optional - CSS class utilities)
**Total: 25 components**
---
## CSS File Structure
```
wwwroot/
├── css/
│ ├── variables.css # CSS custom properties (design tokens)
│ ├── reset.css # Modern CSS reset
│ ├── base.css # Base typography and body styles
│ ├── layout.css # Layout utilities (flex, grid, container)
│ ├── components.css # Component-specific styles
│ ├── animations.css # Animations and transitions
│ └── app.css # Main file that imports all others
├── logo.webp
├── robots.txt
└── index.html
```
---
## Key Syntax Conversions
| React/JSX | Blazor/Razor |
|-----------|--------------|
| `className="..."` | `class="..."` |
| `{variable}` | `@variable` |
| `{condition && <Element />}` | `@if (condition) { <Element /> }` |
| `{items.map(item => ...)}` | `@foreach (var item in items) { ... }` |
| `const [state, setState] = useState()` | `private bool state;` + `StateHasChanged()` |
| `onClick={handler}` | `@onclick="Handler"` |
| `onChange={handler}` | `@onchange="Handler"` |
| `<Component prop={value} />` | `<Component Prop="@value" />` |
| Props interface | `[Parameter]` properties |
| `import Component from './Component'` | `@using` or implicit |
---
## Dependencies Eliminated
### Removed Libraries
- ❌ Tailwind CSS → ✅ Vanilla CSS
- ❌ PostCSS → ✅ None
- ❌ Autoprefixer → ✅ Modern browsers only
- ❌ React Icons → ✅ Inline SVG
- ❌ Headless UI → ✅ Native HTML + CSS
- ❌ React Router DOM → ✅ Blazor Router (built-in)
- ❌ Vite → ✅ .NET SDK
- ❌ TypeScript → ✅ C#
- ❌ ESLint → ✅ Roslyn analyzers (built-in)
- ❌ Node.js → ✅ None
### Added Technology
- ✅ Static server-side rendering (Blazor SSR mode)
- ✅ Pure CSS interactivity techniques (checkbox hack, radio buttons, :target pseudo-class)
### Build Dependencies
- Before: Node.js, npm, Vite, TypeScript compiler, Tailwind CLI
- After: .NET SDK only
### Hosting Requirements
- Before: Static hosting (Azure Static Web Apps, any CDN)
- After: ASP.NET Core server (Azure App Service, Container Apps, or any host supporting .NET)
---
## Risk Assessment
### High Risk (Requires Testing)
- **Mobile menu with CSS checkbox hack:** CSS-only implementation complexity
- *Mitigation:* Well-established pattern, test thoroughly across browsers
- **Tabs with CSS (radio buttons or :target):** CSS-only implementation complexity
- *Mitigation:* Test both approaches, choose most accessible
- **Icon system:** 11 icons need SVG paths
- *Mitigation:* Download from FontAwesome/Simple Icons, test early
- **CSS-only accessibility:** Ensuring keyboard navigation works properly
- *Mitigation:* Thorough accessibility testing with screen readers
### Medium Risk
- **CSS conversion from Tailwind:** Large effort
- *Mitigation:* Component-specific CSS, test each component
- **Server hosting:** Requires server infrastructure instead of static hosting
- *Mitigation:* Azure App Service is straightforward to configure
- **Browser compatibility for CSS tricks:** Checkbox hack and advanced selectors
- *Mitigation:* Test on all major browsers, provide fallbacks if needed
### Low Risk
- **Basic component migration:** Straightforward
- **Routing:** Blazor router is similar to React Router
- **Static assets:** Simple copy operation
---
## Success Criteria
- [ ] Zero npm/Node.js dependencies
- [ ] Zero CSS framework dependencies
- [ ] Zero JavaScript (pure CSS interactivity)
- [ ] All pages render identically to React version
- [ ] Mobile menu works smoothly (CSS checkbox hack)
- [ ] Tabs work smoothly (CSS radio buttons or :target)
- [ ] Site loads in < 2 seconds on 4G (server-rendered)
- [ ] Site works with JavaScript disabled
- [ ] No WebSocket connections present
- [ ] No SignalR scripts loaded
- [ ] No JavaScript files served
- [ ] Analytics functional (server-side or noscript fallback)
- [ ] SEO tags correct
- [ ] Mobile responsive
- [ ] Cross-browser compatible (including CSS-only features)
- [ ] Keyboard accessible (Tab/Enter/Space navigation)
- [ ] Lighthouse score: 95+ (Performance, Accessibility, SEO)
- [ ] Server resource usage minimal (stateless requests only)
---
## Estimated Timeline
- **Phase 1-2 (Setup & CSS):** 2-3 days
- **Phase 3 (Icons):** 0.5-1 day
- **Phase 4 (Core Structure):** 1-2 days
- **Phase 5-6 (Pages & Components):** 3-4 days
- **Phase 7 (Interactive - CSS only):** 1-2 days
- **Phase 8 (CSS Conversion):** 2-3 days
- **Phase 9 (SEO & Analytics):** 0.5-1 day
- **Phase 10-11 (Build & Deploy):** 1-2 days (server configuration)
- **Phase 12 (Testing):** 2-3 days (including CSS-only interactivity testing)
- **Phase 13-14 (Docs & Deploy):** 1 day
**Total: 14-22 days** (depending on CSS approach and server setup complexity)
---
## Notes
- This plan prioritizes **simplicity** and **maintainability** over feature richness
- No external dependencies means **no breaking changes** from library updates
- Vanilla CSS may take longer initially but is **easier to maintain** long-term
- Static SSR provides **instant page loads** with server-side rendering and excellent SEO
- **Zero JavaScript** - pure CSS interactivity (checkbox hack, radio buttons) - **no framework hydration, no parsing overhead**
- The site will be **future-proof** with minimal maintenance requirements
- Focus on **modern CSS features** (Grid, Flexbox, Custom Properties) rather than utility classes
- Test frequently throughout migration - **don't wait until the end**
- **Tradeoff:** Requires server hosting instead of static hosting, but provides better initial load performance
- **Key Benefits:**
- No WebSocket connections = lower server resource usage, simpler architecture, no reconnection issues
- No JavaScript = works with JS disabled, faster TTI, smaller payload, privacy-friendly
- Accessible by default (native HTML form controls)

5
my-portfolio.slnx Normal file
View File

@@ -0,0 +1,5 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/BlazorApp/BlazorApp.csproj" />
</Folder>
</Solution>

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="Components\Shared\Icon.razor"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.15.0-beta.1" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.15.0" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.15.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
@* External link component *@
<a href="@Href"
target="@Target"
class="@CombinedClasses">@ChildContent</a>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string? Href { get; set; }
[Parameter]
public string Target { get; set; } = "_blank";
[Parameter]
public string? CssClass { get; set; }
private string CombinedClasses => string.IsNullOrEmpty(CssClass)
? "underline underline-offset-2"
: $"underline underline-offset-2 {CssClass}";
}

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport"
content="width=device-width, initial-scale=1.0"/>
<base href="/"/>
<ResourcePreloader/>
<!-- Styles -->
<link rel="stylesheet"
href="css/app.css"/>
<ImportMap/>
<!-- Standard Favicons -->
<link rel="icon"
type="image/x-icon"
href="images/favicon.ico"/>
<link rel="icon"
type="image/png"
sizes="16x16"
href="images/favicon-16x16.png"/>
<link rel="icon"
type="image/png"
sizes="32x32"
href="images/favicon-32x32.png"/>
<!-- Apple Touch Icons -->
<link rel="apple-touch-icon"
sizes="180x180"
href="images/apple-touch-icon.png"/>
<!-- Android/Chrome -->
<link rel="manifest"
href="site.webmanifest"/>
<!-- Microsoft Tiles -->
<meta name="msapplication-TileColor"
content="#1a1a1a"/>
<meta name="msapplication-config"
content="browserconfig.xml"/>
<!-- Theme Color -->
<meta name="theme-color"
content="#1a1a1a"/>
<!-- Open Graph / Social Sharing -->
<meta property="og:image"
content="images/og-image.png"/>
<meta property="og:image:width"
content="1200"/>
<meta property="og:image:height"
content="630"/>
<HeadOutlet/>
</head>
<body>
<Routes/>
<script src="@Assets["_framework/blazor.web.js"]"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
@* Contact call-to-action *@
<div class="contact-section">
<Text>If you think I can help with your project...</Text>
<a href="mailto:me@beaufindlay.com"
class="contact-button">
Get in touch
<Icon Type="IconType.SendArrow"/>
</a>
</div>

View File

@@ -0,0 +1,125 @@
@switch (Type)
{
case IconType.Github:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
break;
case IconType.LinkedIn:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
break;
case IconType.Email:
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"/>
</svg>
break;
case IconType.Code:
<svg viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M17.25 6.75L22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3l-4.5 16.5"/>
</svg>
break;
case IconType.Hosting:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M3 3h18v18H3V3zm16 16V5H5v14h14zm-8-2h2v-2h2v-2h-2v-2h2V9h-2V7h-2v2H9v2h2v2H9v2h2v2z"/>
</svg>
break;
case IconType.Blazor:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M15.5 2.5c-2.5 0-4.5 2-4.5 4.5v2.5l-2.5 2.5c-1.5 1.5-1.5 4 0 5.5s4 1.5 5.5 0L16.5 15V12.5c0-2.5 2-4.5 4.5-4.5V6c-2.5 0-4.5-2-4.5-4.5h-1zm-3 7.5v2l-1.5 1.5c-.8.8-2.2.8-3 0s-.8-2.2 0-3L9.5 9h3zm3-5c.8 0 1.5.7 1.5 1.5s-.7 1.5-1.5 1.5-1.5-.7-1.5-1.5.7-1.5 1.5-1.5z"/>
</svg>
break;
case IconType.React:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<circle cx="12"
cy="12"
r="2"/>
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
<ellipse cx="12"
cy="12"
rx="8"
ry="3"
fill="none"
stroke="currentColor"
stroke-width="1"/>
<ellipse cx="12"
cy="12"
rx="3"
ry="8"
fill="none"
stroke="currentColor"
stroke-width="1"/>
</svg>
break;
case IconType.Database:
<svg viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75"/>
</svg>
break;
case IconType.Docker:
<svg viewBox="0 0 24 24"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg">
<path d="M13 2v7h7c0-3.87-3.13-7-7-7zm-2 0C7.13 2 4 5.13 4 9h7V2zM4 11c0 3.87 3.13 7 7 7v-7H4zm9 7c3.87 0 7-3.13 7-7h-7v7z"/>
</svg>
break;
case IconType.SendArrow:
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M6 12L3.269 3.126A59.768 59.768 0 0121.485 12 59.77 59.77 0 013.27 20.876L5.999 12zm0 0h7.5"/>
</svg>
break;
case IconType.Menu:
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"/>
</svg>
break;
}
@code {
[Parameter]
public IconType Type { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace BlazorApp.Components.Icons;
public enum IconType
{
Github,
LinkedIn,
Email,
Code,
Hosting,
Blazor,
React,
Database,
Docker,
SendArrow,
Menu
}

View File

@@ -0,0 +1,19 @@
@* Social media links *@
<div class="social-icons-container">
<a href="https://github.com/bdfin"
class="social-icon-link">
<span class="sr-only">GitHub</span>
<Icon Type="IconType.Github"/>
</a>
<a href="https://www.linkedin.com/in/beau-findlay/"
class="social-icon-link">
<span class="sr-only">LinkedIn</span>
<Icon Type="IconType.LinkedIn"/>
</a>
<a href="mailto:me@beaufindlay.com"
class="social-icon-link">
<span class="sr-only">Email</span>
<Icon Type="IconType.Email"/>
</a>
</div>

View File

@@ -0,0 +1,18 @@
@* Footer component *@
<div class="page-footer">
<footer>
<div class="footer-container">
<div class="footer-content">
<a href="/privacy-policy"
class="footer-link">Privacy Policy</a>
<p class="footer-text">
&copy; @DateTime.Now.Year Beau Findlay. All rights reserved.
</p>
<SocialIcons/>
</div>
</div>
</footer>
</div>

View File

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

View File

@@ -0,0 +1,70 @@
@* Navigation bar with mobile menu *@
<div class="navbar">
<header>
<nav aria-label="Global">
<div class="logo-container">
<a href="/"
class="logo-link">
<span class="sr-only">Beau Findlay</span>
<img src="images/logo.webp"
alt="Logo"/>
</a>
</div>
<div class="mobile-menu-button-container">
<label for="mobile-menu-toggle"
class="menu-button">
<span class="sr-only">Open main menu</span>
<Icon Type="IconType.Menu"/>
</label>
</div>
<div class="desktop-nav">
<NavLink href="/experience">My Experience</NavLink>
<NavLink href="/about">This app</NavLink>
</div>
</nav>
@* Mobile menu using CSS checkbox hack *@
<input type="checkbox"
id="mobile-menu-toggle"
class="menu-toggle"/>
<div class="mobile-menu-overlay"></div>
<div class="mobile-menu-content">
<div class="mobile-menu-inner">
<div class="mobile-menu-header">
<a href="/"
class="logo-link">
<span class="sr-only">Beau Findlay</span>
<img src="images/logo.webp"
alt="Logo"/>
</a>
<label for="mobile-menu-toggle"
class="close-button">
<span class="sr-only">Close menu</span>
<svg xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true">
<path stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"/>
</svg>
</label>
</div>
<div class="mobile-menu-body">
<div class="mobile-nav-links">
<NavLink href="/experience">My Experience</NavLink>
<NavLink href="/about">This App</NavLink>
</div>
<div class="mobile-social-divider">
<div class="mobile-social-container">
<SocialIcons/>
</div>
</div>
</div>
</div>
</div>
</header>
</div>

View File

@@ -0,0 +1,33 @@
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
<a class="navbar-brand"
href="">BlazorApp</a>
</div>
</div>
<input type="checkbox"
title="Navigation menu"
class="navbar-toggler"/>
<div class="nav-scrollable"
onclick="document.querySelector('.navbar-toggler').click()">
<nav class="nav flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link"
href=""
Match="NavLinkMatch.All">
<span class="bi bi-house-door-fill-nav-menu"
aria-hidden="true"></span> Home
</NavLink>
</div>
<div class="nav-item px-3">
<NavLink class="nav-link"
href="weather">
<span class="bi bi-list-nested-nav-menu"
aria-hidden="true"></span> Weather
</NavLink>
</div>
</nav>
</div>

View File

@@ -0,0 +1,75 @@
@page "/about"
<PageTitle>Beau Findlay - About</PageTitle>
<Title CssClass="text-center pb-4">This App</Title>
<Text>
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
<AnchorLink Href="https://github.com/bdfin/my-portfolio">
GitHub
</AnchorLink>.
</Text>
<section>
<Subtitle>The App</Subtitle>
<Text>
This app was originally made using
<AnchorLink Href="https://dotnet.microsoft.com/en-us/apps/aspnet/web-apps/blazor">.NET Blazor WASM</AnchorLink>
and then re-written in
<AnchorLink Href="https://react.dev/">React</AnchorLink>
with
<AnchorLink Href="https://www.typescriptlang.org/">TypeScript</AnchorLink>
as a learning exercise. I've now migrated it back to .NET Blazor to take advantage of
<AnchorLink Href="https://learn.microsoft.com/en-us/aspnet/core/blazor/components/render-modes?view=aspnetcore-10.0#static-server-side-rendering-static-ssr">
SSR (static server-side rendering)
</AnchorLink>
for maximum performance and to remove unnecessary dependencies on large JS and CSS libraries.
</Text>
<Text>
Although SSR sacrifices client-side interactivity, for simple sites like this it means that page loads are
practically instant and appear to function similarly to SPAs (the browser doesn't reload on navigations, new
content is simply swapped in) but without the initial large download of the full application and it's
dependencies.
</Text>
<Text>
This version uses pure vanilla CSS with CSS variables for theming, eliminating all JavaScript and external
dependencies; the mobile menu uses a CSS checkbox hack for zero-JavaScript interactivity.
</Text>
</section>
<section class="mt-8">
<Subtitle>Hosting & Deployment</Subtitle>
<Text>
When this app was written in Blazor WASM and then React (both client-side-only technologies) it was hosted on an
<AnchorLink Href="https://learn.microsoft.com/en-us/azure/static-web-apps/overview">Azure Static Web App
</AnchorLink>
and deployed via
<AnchorLink Href="https://github.com/features/actions">GitHub Actions</AnchorLink>. This provided an easy and cost effective way to get something out there without worrying too much about the
infrastructure. With this being a personal project I needed to keep the costs minimal and even though I would've
preferred a server to work with there wasn't many free options.
</Text>
<Text>
I've always been a bit of a networking/homelab/server nerd so as soon as Cloudflare announced their
<AnchorLink Href="https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/">
Tunnels
</AnchorLink>
feature I decided to revisit this site, rebuild in a server-side technology and host it myself on a tiny,
low-power
<AnchorLink Href="https://www.raspberrypi.com/">RaspberryPi</AnchorLink>
computer running a headless linux operating system.
</Text>
<Text>
I setup the RaspberryPi with
<AnchorLink Href="https://www.docker.com/">Docker</AnchorLink>
and
<AnchorLink Href="https://docs.docker.com/compose/">Docker Compose</AnchorLink>, built a Docker container for the .NET app to run from, copied it to my server and created a docker-compose.yml
file to run it with the
<AnchorLink Href="https://hub.docker.com/r/cloudflare/cloudflared">Cloudflare Tunnel</AnchorLink>
docker image.
</Text>
</section>

View File

@@ -0,0 +1,42 @@
@page "/Error"
@using System.Diagnostics
<PageTitle>Error</PageTitle>
<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>
@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@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>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong>
environment variable to <strong>Development</strong>
and restarting the app.
</p>
@code{
[CascadingParameter]
private HttpContext? HttpContext { get; set; }
private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
protected override void OnInitialized()
{
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
}

View File

@@ -0,0 +1,107 @@
@page "/experience"
<PageTitle>Beau Findlay - Experience</PageTitle>
<Title CssClass="text-center">My Experience</Title>
<p class="text-center text-xl font-semibold mb-10 ">
Software Engineer since 2018
</p>
<ol class="timeline">
@foreach (var item in experienceTimelineItems)
{
<li class="timeline-item">
<time class="timeline-date">
@item.StartDate - @(item.EndDate ?? "Present")
</time>
<h3 class="timeline-title">
@item.Title @("@") <AnchorLink Href="@item.CompanyUrl">@item.CompanyName</AnchorLink>
</h3>
@foreach (var content in item.Content)
{
<Text>@content</Text>
}
</li>
}
</ol>
<Contact/>
@code {
private readonly List<WorkTimelineItem> experienceTimelineItems =
[
new()
{
StartDate = "September 2021",
Title = "CTO",
CompanyName = "un:hurd music",
CompanyUrl = "https://unhurdmusic.com",
Content =
[
"As one of the founding developers at un:hurd music and now Chief Technology Officer, I built and scaled un:hurd's back-end and cloud infrastructure that serves automated marketing soloutions for tens-of-thousands of artists and musicians.",
"I lead a small but incredibly talented multi-disciplinary team building on the Azure cloud using a .NET backend, React web front-end and a Swift native iOS app."
]
},
new()
{
StartDate = "August 2020",
EndDate = "September 2021",
Title = "Software Development Lead",
CompanyName = "Vouch",
CompanyUrl = "https://vouch.co.uk/",
Content =
[
"At Vouch I lead the backend build of a new version of their tenant referencing software - an AI enhanced chat-bot based system utlising Azure Cognitive Services and various supporting serverless APIs written in .NET Core and hosted on Microsoft Azure."
]
},
new()
{
StartDate = "May 2020",
EndDate = "July 2020",
Title = "Software Developer",
CompanyName = "Paragon ID",
CompanyUrl = "https://www.paragon-id.com/en",
Content =
[
"I joined Paragon ID on a short-term contract where I wrote and deployed two key projects: A complex dashboard for a large construction equipment manufacturer to track assets across various manufacturing stages and a medical assets tracking dashboard deployed and used in multiple hospitals across the UK."
]
},
new()
{
StartDate = "July 2019",
EndDate = "May 2020",
Title = "Software Developer",
CompanyName = "Osborne Technologies",
CompanyUrl = "https://www.osbornetechnologies.co.uk/",
Content =
[
"I joined Osborne Technologies as the only cloud cloud-specialist and lead a project creating the first web-based version of their flag ship visitor management software utilising ASP.NET Core MVC and Microsoft SQL Server on the Microsoft Azure cloud."
]
},
new()
{
StartDate = "September 2018",
EndDate = "September 2019",
Title = " MSc Computing Student",
CompanyName = "Sheffield Hallam University",
CompanyUrl = "https://www.shu.ac.uk/courses/computing/msc-computing/full-time",
Content =
[
"I joined Sheffield Hallam University to study for a Master of Science in Computing. During my time there I completed modules in computer programming and web development, databases and big data, computer hardware, project management and my software development thesis; a .NET web application that compiles astronomy and space exploration data from various APIs into an accessible calendar."
]
}
];
private class WorkTimelineItem
{
public string StartDate { get; init; } = string.Empty;
public string? EndDate { get; init; }
public string Title { get; init; } = string.Empty;
public string CompanyName { get; init; } = string.Empty;
public string CompanyUrl { get; init; } = string.Empty;
public string[] Content { get; init; } = [];
}
}

View File

@@ -0,0 +1,21 @@
@page "/"
<PageTitle>Beau Findlay - Home</PageTitle>
<Title CssClass="text-center mb-8">Hi, I'm Beau.</Title>
<Text>
I'm a UK-based software engineer and I love building cool stuff.
</Text>
<Text>
I specialise in C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users.
</Text>
<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
<AnchorLink Href="https://unhurdmusic.com">un:hurd music</AnchorLink>
.
</Text>
<Text>
I believe in a privacy-first, information-focussed and performant internet. You won't find any trackers, analytics
or the need for a cookie banner here.
</Text>

View File

@@ -0,0 +1,13 @@
@page "/not-found"
<PageTitle>Beau Findlay - Not Found</PageTitle>
<main class="grid min-h-full place-items-center px-6 py-24 sm:py-32 lg:px-8">
<div class="text-center">
<p class="text-base font-semibold">404</p>
<h1 class="mt-4 text-4xl font-bold tracking-tight">
Page not found
</h1>
<p class="mt-6 text-base leading-7">Sorry, this page doesn't exist.</p>
</div>
</main>

View File

@@ -0,0 +1,53 @@
@page "/privacy-policy"
<PageTitle>Beau Findlay - Privacy Policy</PageTitle>
<Title CssClass="text-center mb-6">Privacy Policy</Title>
<i>Last updated: 31 January 2025</i>
<section>
<Subtitle>About this website</Subtitle>
<Text>This is a personal website operated by Beau Findlay.</Text>
</section>
<section>
<Subtitle>Information collection</Subtitle>
<Text>This website does not collect, store, or process any personal data and I will never track you or collect your
information.
</Text>
</section>
<section>
<Subtitle>Cookies</Subtitle>
<Text>
This website uses Cloudflare for security and performance. Cloudflare may set cookies on your device under
certain circumstances (such as when verifying you're not a bot):
</Text>
<ul class="mb-6">
<li><em class="font-bold">__cf_bm</em> - Used for bot detection (expires after 30 minutes)</li>
<li><em class="font-bold">cf_clearance</em> - Stores proof you passed a security challenge (expires within 24
hours)
</li>
</ul>
<Text>
These cookies are only set when needed, are strictly necessary for the website to function securely, and are not
used for tracking or advertising. Under normal browsing, no cookies are set.
</Text>
<Text>Cloudflare may process technical data to provide these services. See
<AnchorLink Href="https://www.cloudflare.com/privacypolicy/">Cloudflare's Privacy Policy</AnchorLink>
for details.
</Text>
</section>
<section>
<Subtitle>Contact</Subtitle>
<Text>If you have questions about this policy, you can reach me by <a href="mailto:me@beaufindlay.com"
class="underline">email</a>.
</Text>
</section>

View File

@@ -0,0 +1,8 @@
@using BlazorApp.Components.Pages
<Router AppAssembly="typeof(Program).Assembly"
NotFoundPage="typeof(NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData"
DefaultLayout="typeof(MainLayout)"/>
</Found>
</Router>

View File

@@ -0,0 +1,17 @@
@* H2 heading component *@
<h2 class="@CombinedClasses">@ChildContent</h2>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string? CssClass { get; set; }
private string CombinedClasses => string.IsNullOrEmpty(CssClass)
? "flex items-center text-2xl py-4 font-semibold"
: $"flex items-center text-2xl py-4 font-semibold {CssClass}";
}

View File

@@ -0,0 +1,17 @@
@* Paragraph component *@
<p class="@CombinedClasses">@ChildContent</p>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string? CssClass { get; set; }
private string CombinedClasses => string.IsNullOrEmpty(CssClass)
? "text-paragraph"
: $"text-paragraph {CssClass}";
}

View File

@@ -0,0 +1,17 @@
@* H1 heading component *@
<h1 class="@CombinedClasses">@ChildContent</h1>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
[Parameter]
public string? CssClass { get; set; }
private string CombinedClasses => string.IsNullOrEmpty(CssClass)
? "text-4xl py-4"
: $"text-4xl py-4 {CssClass}";
}

View File

@@ -0,0 +1,17 @@
@using System.Net.Http
@using System.Net.Http.Json
@using Microsoft.AspNetCore.Components.Forms
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.Web
@using static Microsoft.AspNetCore.Components.Web.RenderMode
@using Microsoft.AspNetCore.Components.Web.Virtualization
@using Microsoft.JSInterop
@using BlazorApp
@using BlazorApp.Components
@using BlazorApp.Components.Layout
@using BlazorApp.Components.Icons
@using BlazorApp.Components.AnchorLink
@using BlazorApp.Components.Contact
@using BlazorApp.Components.Typography.Title
@using BlazorApp.Components.Typography.Subtitle
@using BlazorApp.Components.Typography.Text

40
src/BlazorApp/Program.cs Normal file
View File

@@ -0,0 +1,40 @@
using BlazorApp.Components;
using OpenTelemetry.Metrics;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorComponents();
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddAspNetCoreInstrumentation();
metrics.AddPrometheusExporter();
});
var app = builder.Build();
app.MapGet("/health", () => Results.Ok(new
{
status = "healthy",
timestamp = DateTime.UtcNow
}));
app.MapPrometheusScrapingEndpoint();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", true);
app.UseHsts();
}
app.UseStatusCodePagesWithReExecute("/not-found", createScopeForStatusCodePages: true);
app.UseHttpsRedirection();
app.UseAntiforgery();
app.MapStaticAssets();
app.MapRazorComponents<App>();
app.Run();

View File

@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5064",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7162;http://localhost:5064",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square70x70logo src="/images/mstile-70x70.png"/>
<square150x150logo src="/images/mstile-150x150.png"/>
<square310x310logo src="/images/mstile-310x310.png"/>
<TileColor>#1a1a1a</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

@@ -0,0 +1,110 @@
/* Animations */
/* Fade In */
.fade-in {
animation: fadeInAnimation ease 1s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Slide */
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slideOutRight {
from {
transform: translateX(0);
}
to {
transform: translateX(100%);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in-up {
animation: fadeInUp var(--transition-slow) var(--transition-timing);
}
/* Spinner */
.spinner {
display: inline-block;
width: 24px;
height: 24px;
border: 3px solid var(--color-slate-700);
border-top-color: var(--color-slate-50);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Pulse */
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Transitions */
.transition {
transition: all var(--transition-base) var(--transition-timing);
}
.transition-colors {
transition: color var(--transition-base) var(--transition-timing),
background-color var(--transition-base) var(--transition-timing),
border-color var(--transition-base) var(--transition-timing);
}
.transition-transform {
transition: transform var(--transition-base) var(--transition-timing);
}
.transition-opacity {
transition: opacity var(--transition-base) var(--transition-timing);
}
/* Hover */
.hover-scale:hover {
transform: scale(1.05);
}
.hover-lift:hover {
transform: translateY(-2px);
}

View File

@@ -0,0 +1,9 @@
/* Main Stylesheet */
@import url('variables.css');
@import url('reset.css');
@import url('base.css');
@import url('utilities.css');
@import url('layout.css');
@import url('components.css');
@import url('animations.css');

View File

@@ -0,0 +1,114 @@
/* Base Styles */
body {
background-color: var(--color-black);
color: var(--color-slate-50);
font-family: var(--font-mono);
font-size: var(--font-size-base);
line-height: var(--line-height-normal);
}
/* Typography */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
}
h1 {
font-size: var(--font-size-3xl);
}
h2 {
font-size: var(--font-size-2xl);
}
h3 {
font-size: var(--font-size-xl);
}
p {
margin-bottom: var(--space-4);
}
/* Lists */
ul {
list-style: none;
padding-left: var(--space-4);
}
ul li {
position: relative;
padding-left: var(--space-6);
margin-bottom: var(--space-3);
}
ul li::before {
content: ">";
position: absolute;
left: 0;
color: var(--color-slate-400);
font-size: var(--font-size-base);
line-height: 1.5;
font-weight: var(--font-weight-bold);
}
/* Links */
a {
color: var(--color-slate-50);
display: inline-block;
transition: transform var(--transition-base) var(--transition-timing);
}
a:hover {
transform: translateY(2px);
color: var(--color-slate-50);
}
a:focus-visible {
outline: 2px solid var(--color-slate-50);
outline-offset: 2px;
}
/* Focus */
*:focus-visible {
outline: 2px solid var(--color-slate-50);
outline-offset: 2px;
}
button:focus-visible,
input:focus-visible,
textarea:focus-visible,
select:focus-visible {
outline: 2px solid var(--color-slate-50);
outline-offset: 2px;
}
/* Selection */
::selection {
background-color: var(--color-slate-50);
color: var(--color-black);
}
/* Scrollbar */
body::-webkit-scrollbar {
width: 10px;
}
body::-webkit-scrollbar-track {
background: var(--color-white);
}
body::-webkit-scrollbar-thumb {
background-color: var(--color-black);
border: 1px solid var(--color-white);
}
body {
scrollbar-width: thin;
scrollbar-color: var(--color-black) var(--color-white);
}

View File

@@ -0,0 +1,596 @@
/* Components */
/* Buttons */
.btn {
display: inline-block;
padding: var(--space-3) var(--space-6);
font-weight: var(--font-weight-medium);
text-align: center;
border-radius: var(--radius-md);
transition: all var(--transition-base) var(--transition-timing);
cursor: pointer;
}
.btn:focus-visible {
outline: 2px solid var(--color-slate-50);
outline-offset: 2px;
}
.btn-primary {
background-color: var(--color-slate-50);
color: var(--color-black);
}
.btn-primary:hover {
background-color: var(--color-slate-200);
}
.btn-secondary {
background-color: transparent;
color: var(--color-slate-50);
border: 1px solid var(--color-slate-50);
}
.btn-secondary:hover {
background-color: var(--color-slate-50);
color: var(--color-black);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Inputs */
.input {
width: 100%;
padding: var(--space-3);
background-color: var(--color-black);
border: 1px solid var(--color-slate-700);
border-radius: var(--radius-md);
color: var(--color-slate-50);
font-size: var(--font-size-base);
}
.input:focus {
outline: none;
border-color: var(--color-slate-50);
}
.input:invalid {
border-color: #ef4444;
}
.input::placeholder {
color: var(--color-slate-500);
}
/* Textareas */
.textarea {
width: 100%;
padding: var(--space-3);
background-color: var(--color-black);
border: 1px solid var(--color-slate-700);
border-radius: var(--radius-md);
color: var(--color-slate-50);
font-size: var(--font-size-base);
resize: vertical;
min-height: 120px;
}
.textarea:focus {
outline: none;
border-color: var(--color-slate-50);
}
/* Labels */
.label {
display: block;
margin-bottom: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
}
/* Cards */
.card {
background-color: var(--color-slate-900);
border-radius: var(--radius-lg);
padding: var(--space-6);
border: 1px solid var(--color-slate-800);
}
/* NavBar */
.navbar header {
padding-top: var(--space-6);
}
.navbar nav {
margin-left: auto;
margin-right: auto;
display: flex;
max-width: 80rem;
align-items: center;
justify-content: space-between;
}
.navbar .logo-container {
display: flex;
}
.navbar .logo-link {
margin: -0.375rem;
padding: 0.375rem;
}
.navbar .logo-link img {
height: 4rem;
width: auto;
}
.navbar .mobile-menu-button-container {
display: flex;
}
.navbar .menu-button {
margin: -0.625rem;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-md);
padding: 0.625rem;
cursor: pointer;
}
.navbar .menu-button svg {
height: 1.5rem;
width: 1.5rem;
}
.navbar .desktop-nav {
display: none;
}
.navbar .desktop-nav a {
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
line-height: 1.5;
}
/* Mobile Menu (CSS-only) */
.menu-toggle {
display: none;
}
.mobile-menu-overlay {
display: none;
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
z-index: var(--z-overlay);
}
.menu-toggle:checked ~ .mobile-menu-overlay {
display: block;
}
.mobile-menu-content {
display: none;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: var(--z-modal);
background-color: var(--color-black);
width: 100%;
padding: var(--space-6);
color: white;
transform: translateX(100%);
transition: transform var(--transition-slow) var(--transition-timing);
}
.menu-toggle:checked ~ .mobile-menu-content {
display: block;
transform: translateX(0);
}
.mobile-menu-inner {
display: flex;
flex-direction: column;
height: 100%;
}
.mobile-menu-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.close-button {
margin: -0.625rem;
border-radius: var(--radius-md);
padding: 0.625rem;
cursor: pointer;
}
.close-button svg {
height: 1.5rem;
width: 1.5rem;
}
.mobile-menu-body {
margin-top: var(--space-6);
flex: 1 1 0;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.mobile-nav-links {
margin-top: var(--space-2);
}
.mobile-nav-links a {
margin-left: -0.75rem;
margin-right: -0.75rem;
display: block;
border-radius: var(--radius-lg);
padding: var(--space-2) 0.75rem;
font-size: var(--font-size-base);
font-weight: var(--font-weight-semibold);
line-height: 1.75;
}
.mobile-social-divider {
border-top: 1px solid rgba(226, 232, 240, 0.1);
padding-top: var(--space-8);
}
.mobile-social-container {
display: flex;
justify-content: center;
align-items: center;
}
/* Prevent body scroll when mobile menu is open */
.menu-toggle:checked ~ * {
overflow: hidden;
}
body:has(.menu-toggle:checked) {
overflow: hidden;
}
@media (min-width: 640px) {
.mobile-menu-content {
max-width: 24rem;
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
}
@media (min-width: 1024px) {
.navbar .logo-container {
flex: 1 1 0;
}
.navbar .mobile-menu-button-container {
display: none;
}
.navbar .desktop-nav {
display: flex;
column-gap: var(--space-12);
}
.mobile-menu-content {
display: none;
}
}
/* Footer */
.page-footer footer {
margin-top: auto;
}
.page-footer .footer-container {
margin-left: auto;
margin-right: auto;
padding-top: var(--space-8);
padding-bottom: var(--space-8);
}
.page-footer .footer-content {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
}
.page-footer .footer-link {
font-size: var(--font-size-xs);
color: var(--color-slate-50);
text-decoration: underline;
text-underline-offset: 2px;
}
.page-footer .footer-text {
font-size: var(--font-size-xs);
color: var(--color-slate-50);
margin-bottom: 0;
}
@media (min-width: 768px) {
.page-footer .footer-content {
flex-direction: row;
justify-content: space-between;
}
}
/* Social Icons */
.social-icons-container {
display: flex;
gap: var(--space-6);
}
.social-icon-link {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 40px;
min-height: 40px;
color: var(--color-slate-50);
transition: transform var(--transition-base) var(--transition-timing);
}
.social-icon-link svg {
width: 24px;
height: 24px;
}
.social-icon-link:hover {
transform: translateY(2px);
color: var(--color-slate-50);
}
@media (min-width: 768px) {
.social-icons-container {
order: 2;
}
.social-icon-link {
min-width: auto;
min-height: auto;
}
.social-icon-link svg {
width: 20px;
height: 20px;
}
}
/* Tech Icons */
.tech-icons-wrapper {
margin-left: auto;
margin-right: auto;
max-width: 64rem;
}
.tech-icons-title {
font-size: var(--font-size-xl);
text-align: center;
margin-bottom: var(--space-10);
font-weight: var(--font-weight-semibold);
}
.tech-icons-grid {
display: flex;
flex-direction: column;
gap: var(--space-6);
text-align: center;
margin-left: auto;
margin-right: auto;
margin-top: var(--space-4);
}
.tech-icon-item {
display: flex;
flex-direction: column;
align-items: center;
}
.tech-icon-item svg {
width: 34px;
height: 34px;
}
.tech-icon-item p {
margin-top: var(--space-2);
font-size: var(--font-size-sm);
}
@media (min-width: 768px) {
.tech-icons-grid {
flex-direction: row;
justify-content: space-evenly;
gap: 0;
}
}
/* AboutTabs */
.tabs-subtitle {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-lg);
margin-top: var(--space-4);
margin-bottom: var(--space-4);
}
/* Contact Section */
.contact-section {
margin-bottom: var(--space-10);
margin-top: var(--space-12);
text-align: center;
}
.contact-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--color-slate-700);
background-color: var(--color-black);
padding: 0.625rem 0.875rem;
margin-top: var(--space-2);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-semibold);
color: var(--color-slate-50);
transition: transform var(--transition-base) var(--transition-timing);
}
.contact-button:hover {
transform: translateY(2px);
color: var(--color-slate-50);
}
.contact-button:focus-visible {
outline: 2px solid var(--color-slate-50);
outline-offset: 2px;
}
.contact-button svg {
width: 16px;
height: 16px;
flex-shrink: 0;
}
/* Text Paragraph */
.text-paragraph {
font-size: var(--font-size-lg);
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
@media (min-width: 768px) {
.text-paragraph {
padding-top: var(--space-2);
padding-bottom: var(--space-2);
}
}
/* Links */
.link {
color: var(--color-slate-50);
text-decoration: underline;
text-underline-offset: 2px;
}
.link:hover {
color: var(--color-slate-300);
}
/* Icon Buttons */
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: var(--space-2);
transition: opacity var(--transition-base) var(--transition-timing);
}
.icon-button:hover {
opacity: 0.7;
}
/* Timeline */
.timeline {
position: relative;
border-left: 1px solid var(--color-slate-600);
list-style: none;
padding-left: 0;
}
.timeline-item {
margin-bottom: var(--space-10);
margin-left: var(--space-4);
position: relative;
}
.timeline-marker {
position: absolute;
width: 0.75rem;
height: 0.75rem;
background-color: var(--color-slate-600);
border-radius: 50%;
left: -1.875rem;
top: 0.375rem;
border: 2px solid var(--color-black);
}
.timeline-date {
display: block;
margin-bottom: var(--space-1);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-normal);
line-height: 1;
color: var(--color-slate-400);
}
.timeline-title {
font-size: var(--font-size-2xl);
font-weight: var(--font-weight-semibold);
color: var(--color-slate-50);
margin-bottom: var(--space-2);
}
/* Tabs (CSS Radio Buttons) */
.tabs-container {
margin-top: var(--space-4);
}
.tab-radio {
display: none;
}
.tab-labels {
display: flex;
gap: var(--space-8);
border-bottom: 1px solid var(--color-slate-700);
margin-bottom: var(--space-10);
overflow-x: auto;
}
.tab-label {
padding: var(--space-6) 0;
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
color: var(--color-slate-400);
border-bottom: 4px solid transparent;
margin-bottom: -1px;
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-base) var(--transition-timing);
}
.tab-label:hover {
color: var(--color-slate-200);
border-bottom-color: var(--color-slate-700);
}
#tab0:checked ~ .tab-labels label[for="tab0"],
#tab1:checked ~ .tab-labels label[for="tab1"],
#tab2:checked ~ .tab-labels label[for="tab2"],
#tab3:checked ~ .tab-labels label[for="tab3"] {
color: var(--color-slate-200);
border-bottom-color: var(--color-slate-300);
}
.tab-panel {
display: none;
}
#tab0:checked ~ .tab-panels #panel0,
#tab1:checked ~ .tab-panels #panel1,
#tab2:checked ~ .tab-panels #panel2,
#tab3:checked ~ .tab-panels #panel3 {
display: block;
}

View File

@@ -0,0 +1,18 @@
/* Layout */
/* Container */
.container {
width: 100%;
max-width: var(--container-max-width);
margin-left: auto;
margin-right: auto;
padding-left: var(--space-6);
padding-right: var(--space-6);
}
@media (min-width: 1024px) {
.container {
padding-left: var(--space-10);
padding-right: var(--space-10);
}
}

View File

@@ -0,0 +1,77 @@
/* CSS Reset */
*,
*::before,
*::after {
box-sizing: border-box;
}
* {
margin: 0;
padding: 0;
}
html {
-webkit-text-size-adjust: 100%;
-moz-tab-size: 4;
tab-size: 4;
}
html:focus-within {
scroll-behavior: smooth;
}
body {
min-height: 100vh;
line-height: 1.5;
text-rendering: optimizeSpeed;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
input,
button,
textarea,
select {
font: inherit;
}
button {
background: none;
border: none;
cursor: pointer;
}
ul,
ol {
list-style: none;
}
a {
text-decoration: none;
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
html:focus-within {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View File

@@ -0,0 +1,641 @@
/* Utility Classes */
/* Display */
.hidden {
display: none;
}
.block {
display: block;
}
.inline-block {
display: inline-block;
}
.inline-flex {
display: inline-flex;
}
.flow-root {
display: flow-root;
}
/* Flexbox */
.flex {
display: flex;
}
.flex-col {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.flex-1 {
flex: 1 1 0;
}
.items-start {
align-items: flex-start;
}
.items-center {
align-items: center;
}
.items-end {
align-items: flex-end;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
/* Gap */
.gap-1 {
gap: var(--space-1);
}
.gap-2 {
gap: var(--space-2);
}
.gap-3 {
gap: var(--space-3);
}
.gap-4 {
gap: var(--space-4);
}
.gap-6 {
gap: var(--space-6);
}
.gap-8 {
gap: var(--space-8);
}
/* Grid */
.grid {
display: grid;
}
.grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr));
}
.grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.place-items-center {
place-items: center;
}
/* Spacing */
.min-h-screen {
min-height: 100vh;
}
.min-h-full {
min-height: 100%;
}
.h-full {
height: 100%;
}
.h-6 {
height: 1.5rem;
}
.h-16 {
height: 4rem;
}
.w-6 {
width: 1.5rem;
}
.w-auto {
width: auto;
}
.w-full {
width: 100%;
}
/* Max Widths */
.max-w-sm {
max-width: 24rem;
}
.max-w-md {
max-width: 28rem;
}
.max-w-lg {
max-width: 32rem;
}
.max-w-xl {
max-width: 36rem;
}
.max-w-2xl {
max-width: 42rem;
}
.max-w-4xl {
max-width: 64rem;
}
.max-w-7xl {
max-width: 80rem;
}
/* Padding */
.p-2 {
padding: var(--space-2);
}
.p-4 {
padding: var(--space-4);
}
.p-6 {
padding: var(--space-6);
}
.p-8 {
padding: var(--space-8);
}
.p-1\.5 {
padding: 0.375rem;
}
.p-2\.5 {
padding: 0.625rem;
}
.px-2 {
padding-left: var(--space-2);
padding-right: var(--space-2);
}
.px-3 {
padding-left: 0.75rem;
padding-right: 0.75rem;
}
.px-4 {
padding-left: var(--space-4);
padding-right: var(--space-4);
}
.px-6 {
padding-left: var(--space-6);
padding-right: var(--space-6);
}
.px-8 {
padding-left: var(--space-8);
padding-right: var(--space-8);
}
.py-2 {
padding-top: var(--space-2);
padding-bottom: var(--space-2);
}
.py-4 {
padding-top: var(--space-4);
padding-bottom: var(--space-4);
}
.py-6 {
padding-top: var(--space-6);
padding-bottom: var(--space-6);
}
.py-8 {
padding-top: var(--space-8);
padding-bottom: var(--space-8);
}
.py-24 {
padding-top: 6rem;
padding-bottom: 6rem;
}
.pt-6 {
padding-top: var(--space-6);
}
.pt-8 {
padding-top: var(--space-8);
}
.pb-4 {
padding-bottom: var(--space-4);
}
/* Margins */
.m-2 {
margin: var(--space-2);
}
.m-4 {
margin: var(--space-4);
}
.m-6 {
margin: var(--space-6);
}
.m-8 {
margin: var(--space-8);
}
.-m-1\.5 {
margin: -0.375rem;
}
.-m-2\.5 {
margin: -0.625rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
}
.-mx-3 {
margin-left: -0.75rem;
margin-right: -0.75rem;
}
.-my-6 {
margin-top: -1.5rem;
margin-bottom: -1.5rem;
}
.mt-2 {
margin-top: var(--space-2);
}
.mt-4 {
margin-top: var(--space-4);
}
.mt-6 {
margin-top: var(--space-6);
}
.mt-8 {
margin-top: var(--space-8);
}
.mt-12 {
margin-top: var(--space-12);
}
.mt-auto {
margin-top: auto;
}
.mb-2 {
margin-bottom: var(--space-2);
}
.mb-4 {
margin-bottom: var(--space-4);
}
.mb-6 {
margin-bottom: var(--space-6);
}
.mb-8 {
margin-bottom: var(--space-8);
}
.mb-10 {
margin-bottom: var(--space-10);
}
.mb-12 {
margin-bottom: var(--space-12);
}
/* Space Between */
.space-x-6 > * + * {
margin-left: var(--space-6);
}
.space-y-2 > * + * {
margin-top: var(--space-2);
}
.space-y-10 > * + * {
margin-top: var(--space-10);
}
/* Typography */
.text-center {
text-align: center;
}
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.text-xs {
font-size: var(--font-size-xs);
}
.text-sm {
font-size: var(--font-size-sm);
}
.text-base {
font-size: var(--font-size-base);
}
.text-lg {
font-size: var(--font-size-lg);
}
.text-xl {
font-size: var(--font-size-xl);
}
.text-2xl {
font-size: var(--font-size-2xl);
}
.text-3xl {
font-size: var(--font-size-3xl);
}
.text-4xl {
font-size: var(--font-size-4xl);
}
.font-normal {
font-weight: var(--font-weight-normal);
}
.font-medium {
font-weight: var(--font-weight-medium);
}
.font-semibold {
font-weight: var(--font-weight-semibold);
}
.font-bold {
font-weight: var(--font-weight-bold);
}
.leading-tight {
line-height: var(--line-height-tight);
}
.leading-normal {
line-height: var(--line-height-normal);
}
.leading-relaxed {
line-height: var(--line-height-relaxed);
}
.leading-6 {
line-height: 1.5;
}
.leading-7 {
line-height: 1.75;
}
.tracking-tight {
letter-spacing: -0.025em;
}
.underline {
text-decoration: underline;
}
.underline-offset-2 {
text-underline-offset: 2px;
}
.underline-offset-4 {
text-underline-offset: 4px;
}
/* Positioning */
.fixed {
position: fixed;
}
.inset-0 {
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.inset-y-0 {
top: 0;
bottom: 0;
}
/* Border */
.border-t {
border-top: 1px solid rgba(226, 232, 240, 0.1);
}
.divide-y > * + * {
border-top: 1px solid rgba(226, 232, 240, 0.1);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
/* Z-index */
.z-10 {
z-index: var(--z-dropdown);
}
/* Overflow */
.overflow-y-auto {
overflow-y: auto;
}
/* Cursor */
.cursor-pointer {
cursor: pointer;
}
/* Screen Reader Only */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
/* Responsive Utilities */
@media (min-width: 640px) {
.sm\:max-w-sm {
max-width: 24rem;
}
.sm\:ring-1 {
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.sm\:border-l-2 {
border-left: 2px solid var(--color-slate-800);
}
.sm\:py-32 {
padding-top: 8rem;
padding-bottom: 8rem;
}
.sm\:grid-cols-2 {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.sm\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (min-width: 768px) {
.md\:flex {
display: flex;
}
.md\:flex-row {
flex-direction: row;
}
.md\:justify-evenly {
justify-content: space-evenly;
}
.md\:items-center {
align-items: center;
}
.md\:justify-between {
justify-content: space-between;
}
.md\:order-1 {
order: 1;
}
.md\:order-2 {
order: 2;
}
.md\:mt-0 {
margin-top: 0;
}
.md\:py-3 {
padding-top: var(--space-3);
padding-bottom: var(--space-3);
}
.md\:grid-cols-3 {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.md\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1024px) {
.lg\:flex {
display: flex;
}
.lg\:hidden {
display: none;
}
.lg\:block {
display: block;
}
.lg\:px-8 {
padding-left: var(--space-8);
padding-right: var(--space-8);
}
.lg\:px-10 {
padding-left: var(--space-10);
padding-right: var(--space-10);
}
.lg\:flex-1 {
flex: 1 1 0;
}
.lg\:gap-x-12 {
column-gap: var(--space-12);
}
.lg\:grid-cols-4 {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.lg\:grid-cols-5 {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}

View File

@@ -0,0 +1,97 @@
/* Design Tokens */
:root {
/* Colors */
--color-black: #000000;
--color-white: #ffffff;
--color-slate-50: #f8fafc;
--color-slate-100: #f1f5f9;
--color-slate-200: #e2e8f0;
--color-slate-300: #cbd5e1;
--color-slate-400: #94a3b8;
--color-slate-500: #64748b;
--color-slate-600: #475569;
--color-slate-700: #334155;
--color-slate-800: #1e293b;
--color-slate-900: #0f172a;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
/* Typography */
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--font-size-xs: 0.75rem;
--font-size-sm: 0.875rem;
--font-size-base: 1rem;
--font-size-lg: 1.125rem;
--font-size-xl: 1.25rem;
--font-size-2xl: 1.5rem;
--font-size-3xl: 1.875rem;
--font-size-4xl: 2.25rem;
--font-size-5xl: 3rem;
--font-size-6xl: 3.75rem;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Spacing */
--space-0: 0;
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-5: 1.25rem;
--space-6: 1.5rem;
--space-8: 2rem;
--space-10: 2.5rem;
--space-12: 3rem;
--space-16: 4rem;
--space-20: 5rem;
--space-24: 6rem;
/* Breakpoints */
--breakpoint-sm: 640px;
--breakpoint-md: 768px;
--breakpoint-lg: 1024px;
--breakpoint-xl: 1280px;
--breakpoint-2xl: 1536px;
/* Container */
--container-max-width: 80rem;
/* Border Radius */
--radius-sm: 0.125rem;
--radius-base: 0.25rem;
--radius-md: 0.375rem;
--radius-lg: 0.5rem;
--radius-xl: 0.75rem;
--radius-2xl: 1rem;
--radius-full: 9999px;
/* Z-index */
--z-base: 0;
--z-dropdown: 10;
--z-sticky: 20;
--z-fixed: 30;
--z-overlay: 40;
--z-modal: 50;
--z-popover: 60;
--z-tooltip: 70;
/* Transitions */
--transition-fast: 150ms;
--transition-base: 200ms;
--transition-slow: 300ms;
--transition-slower: 500ms;
--transition-timing: cubic-bezier(0.4, 0, 0.2, 1);
/* Shadows */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow-base: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@@ -0,0 +1,19 @@
{
"name": "Beau Findlay's Portfolio",
"short_name": "BF Portfolio",
"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": "#1a1a1a",
"background_color": "#1a1a1a",
"display": "standalone"
}

View File

@@ -1,18 +0,0 @@
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
View File

@@ -1,24 +0,0 @@
# 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?

View File

@@ -1,30 +0,0 @@
# 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

View File

@@ -1,40 +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/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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +0,0 @@
{
"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

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -1,137 +0,0 @@
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

@@ -1,24 +0,0 @@
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

@@ -1,19 +0,0 @@
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

@@ -1,16 +0,0 @@
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

@@ -1,18 +0,0 @@
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

@@ -1,12 +0,0 @@
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

@@ -1,15 +0,0 @@
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

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

View File

@@ -1,13 +0,0 @@
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

@@ -1,23 +0,0 @@
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

@@ -1,92 +0,0 @@
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

@@ -1,25 +0,0 @@
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

@@ -1,40 +0,0 @@
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>
);
}

View File

@@ -1,14 +0,0 @@
import { ReactNode } from "react";
import buildClassNames from "../helpers/cssClassFormatter";
interface Props {
children: ReactNode;
className?: string | null;
}
export default function Subtitle({ children, className }: Props) {
const defaultStyles = "flex items-center text-2xl py-4 font-semibold";
const styles = buildClassNames(className ? className : "", defaultStyles);
return <h2 className={styles}>{children}</h2>;
}

View File

@@ -1,58 +0,0 @@
import { FaDatabase, FaDocker, FaReact } from "react-icons/fa6";
import { SiBlazor, SiCsharp, SiMicrosoftazure } from "react-icons/si";
import buildClassNames from "../helpers/cssClassFormatter";
const iconSize = 34;
const iconCss = "mx-auto";
const techIcons = [
{
name: "C#/.NET",
icon: <SiCsharp size={iconSize} className={iconCss} />,
},
{
name: "Microsoft Azure",
icon: <SiMicrosoftazure size={iconSize} className={iconCss} />,
},
{
name: "Blazor",
icon: <SiBlazor size={iconSize} className={iconCss} />,
},
{
name: "React",
icon: <FaReact size={iconSize} className={iconCss} />,
},
{
name: "Databases",
icon: <FaDatabase size={iconSize} className={iconCss} />,
},
{
name: "Docker",
icon: <FaDocker size={iconSize} className={iconCss} />,
},
];
interface Props {
className?: string | null;
}
export default function TechIcons({ className }: Props) {
const defaultStyles = "mx-auto max-w-4xl";
const styles = buildClassNames(className ? className : "", defaultStyles);
return (
<div className={styles}>
<p className="text-xl text-center mb-10 font-semibold">
Tech i'm working with at the moment:
</p>
<div className="flex flex-col md:flex-row md:justify-evenly space-y-10 md:space-y-0 text-center mx-auto mt-4">
{techIcons.map((techIcon, index) => (
<div key={index}>
{techIcon.icon}
<p className="mt-2 text-sm">{techIcon.name}</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,14 +0,0 @@
import { ReactNode } from "react";
import buildClassNames from "../helpers/cssClassFormatter";
interface Props {
children: ReactNode;
className?: string | null;
}
export default function Text({ children, className }: Props) {
const defaultStyles = "text-lg py-3";
const styles = buildClassNames(className ? className : "", defaultStyles);
return <p className={styles}>{children}</p>;
}

View File

@@ -1,14 +0,0 @@
interface Props {
id: string;
rows?: number;
}
export default function TextAreaInput({ id, rows = 4 }: Props) {
return (
<textarea
id={id}
rows={rows}
className="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"
></textarea>
);
}

View File

@@ -1,14 +0,0 @@
interface Props {
id: string;
type: "text" | "email";
}
export default function TextInput({ id, type }: Props) {
return (
<input
id={id}
type={type}
className="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"
/>
);
}

View File

@@ -1,13 +0,0 @@
import buildClassNames from "../helpers/cssClassFormatter";
interface Props {
children: string;
className?: string | null;
}
export default function Title({ children, className }: Props) {
const defaultStyles = "text-4xl py-4";
const styles = buildClassNames(className ? className : "", defaultStyles);
return <h1 className={styles}>{children}</h1>;
}

View File

@@ -1,76 +0,0 @@
import AnchorLink from "./AnchorLink";
import Text from "./Text";
interface WorkTimelineItem {
startDate: string;
endDate?: string | null;
title: string;
companyName: string;
companyUrl: string;
content: string[];
}
const workTimelineItems: WorkTimelineItem[] = [
{
startDate: "September 2021",
title: "CTO",
companyName: "un:hurd music",
companyUrl: "https://unhurdmusic.com",
content: [
"As one of the founding developers at un:hurd music and now Chief Technology Officer, I built and scaled un:hurd's back-end and cloud infrastructure that serves automated marketing soloutions for tens-of-thousands of artists and musicians.",
"I lead a small but incredibly talented multi-diciplinary team building on the Azure cloud using a .NET backend, React web front-end and a Swift native iOS app.",
],
},
{
startDate: "August 2020",
endDate: "September 2021",
title: "Software Development Lead",
companyName: "Vouch",
companyUrl: "https://vouch.co.uk/",
content: [
"At Vouch I lead the backend build of a new version of their tenant referencing software - an AI enhanced chat-bot based system utlising Azure Cognitive Services and various supporting serverless APIs written in .NET Core and hosted on Microsoft Azure.",
],
},
{
startDate: "May 2020",
endDate: "July 2020",
title: "Software Developer",
companyName: "Paragon ID",
companyUrl: "https://www.paragon-id.com/en",
content: [
"I joined Paragon ID on a short-term contract where I wrote and deployed two key projects: A complex dashboard for a large construction equipment manufacturer to track assets across various manufacturing stages and a medical assets tracking dashboard deployed and used in multiple hospitals across the UK.",
],
},
{
startDate: "July 2019",
endDate: "May 2020",
title: "Software Developer",
companyName: "Osborne Technologies",
companyUrl: "https://www.osbornetechnologies.co.uk/",
content: [
"I joined Osborne Technologies as the only cloud cloud-specialist and lead a project creating the first web-based version of their flag ship visitor management software utilising ASP.NET Core MVC and Microsoft SQL Server on the Microsoft Azure cloud.",
],
},
];
export default function WorkTimeline() {
return (
<ol className="relative border-s border-gray-600">
{workTimelineItems.map((item, index) => (
<li key={index} className="mb-10 ms-4">
<div className="absolute w-3 h-3 rounded-full mt-1.5 -start-1.5 borderborder-gray-900 bg-gray-600"></div>
<time className="mb-1 text-sm font-normal leading-none text-gray-400">
{item.startDate} - {item.endDate ? item.endDate : "Present"}
</time>
<h3 className="text-2xl font-semibold text-gray-900 dark:text-white">
{item.title} @{" "}
<AnchorLink href={item.companyUrl}>{item.companyName}</AnchorLink>
</h3>
{item.content.map((content, index) => (
<Text key={index}>{content}</Text>
))}
</li>
))}
</ol>
);
}

View File

@@ -1,3 +0,0 @@
export default function buildClassNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}

View File

@@ -1,31 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.fade-in {
animation: fadeInAnimation ease 1s;
animation-iteration-count: 1;
animation-fill-mode: forwards;
}
@keyframes fadeInAnimation {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
body::-webkit-scrollbar {
width: 10px;
}
body::-webkit-scrollbar-track {
background: white;
}
body::-webkit-scrollbar-thumb {
background-color: black;
border: 1px solid white;
}

View File

@@ -1,11 +0,0 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import "./index.css";
import router from "./routes.tsx";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>
);

View File

@@ -1,22 +0,0 @@
import AboutTabs from "../components/AboutTabs";
import AnchorLink from "../components/AnchorLink";
import Text from "../components/Text";
import Title from "../components/Title";
export default function AboutPage() {
return (
<>
<Title className="text-center pb-4">This App</Title>
<Text>
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{" "}
<AnchorLink href="https://github.com/bdfin/my-portfolio">
GitHub
</AnchorLink>
.
</Text>
<AboutTabs />
</>
);
}

View File

@@ -1,35 +0,0 @@
import { isRouteErrorResponse, useRouteError } from "react-router-dom";
import Footer from "../components/Footer";
import NavBar from "../components/NavBar";
export default function ErrorPage() {
const error = useRouteError();
let errorCode = "Oops";
let errorTitle = "Something went wrong.";
let errorMessage = "This error has been automatically logged.";
if (isRouteErrorResponse(error)) {
errorCode = "404";
errorTitle = "Page not found";
errorMessage = "Sorry, this page dosen't exist.";
}
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 fade-in">
<NavBar />
<main className="grid min-h-full place-items-center px-6 py-24 sm:py-32 lg:px-8">
<div className="text-center">
<p className="text-base font-semibold ">{errorCode}</p>
<h1 className="mt-4 text-4xl font-bold tracking-tight">
{errorTitle}
</h1>
<p className="mt-6 text-base leading-7 ">{errorMessage}</p>
</div>
</main>
<Footer />
</div>
</div>
);
}

View File

@@ -1,26 +0,0 @@
import AnchorLink from "../components/AnchorLink";
import TechIcons from "../components/TechIcons";
import Text from "../components/Text";
import Title from "../components/Title";
export default function HomePage() {
return (
<>
<Title className="text-center mb-6">Hi, I'm Beau.</Title>
<Text>
I'm a UK-based software engineer and I love building cool stuff.
</Text>
<Text>
I mostly specialise in back-end C#/.NET development and I've built
systems that scale for hundreds-of-thousands of global users.
</Text>
<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{" "}
<AnchorLink href="https://unhurdmusic.com">un:hurd music</AnchorLink>.
</Text>
<TechIcons className="mt-28" />
</>
);
}

View File

@@ -1,17 +0,0 @@
import { Outlet } from "react-router-dom";
import NavBar from "../components/NavBar";
import Footer from "../components/Footer";
export default function Layout() {
return (
<div className="bg-black font-mono text-slate-50 antialiased px-6 lg:px-10">
<div className="flex flex-col min-h-screen mx-auto max-w-7xl fade-in">
<NavBar />
<div className="flex-1 py-8">
<Outlet />
</div>
<Footer />
</div>
</div>
);
}

View File

@@ -1,16 +0,0 @@
import ContactMe from "../components/ContactMe";
import Title from "../components/Title";
import WorkTimeline from "../components/WorkTimeline";
export default function WorkPage() {
return (
<>
<Title className="text-center mb-4">Work</Title>
<p className="text-center text-2xl font-semibold mb-12">
Freelance Software Engineer since 2018
</p>
<WorkTimeline />
<ContactMe />
</>
);
}

View File

@@ -1,21 +0,0 @@
import { createBrowserRouter } from "react-router-dom";
import AboutPage from "./pages/AboutPage";
import ErrorPage from "./pages/ErrorPage";
import HomePage from "./pages/HomePage";
import Layout from "./pages/Layout";
import WorkPage from "./pages/WorkPage";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
errorElement: <ErrorPage />,
children: [
{ index: true, element: <HomePage /> },
{ path: "work", element: <WorkPage /> },
{ path: "about", element: <AboutPage /> },
],
},
]);
export default router;

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

@@ -1,9 +0,0 @@
{
"navigationFallback": {
"rewrite": "index.html",
"exclude": ["/static/media/*.{png,jpg,jpeg,gif,bmp}", "/static/css/*"]
},
"mimeTypes": {
".json": "text/json"
}
}

View File

@@ -1,9 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
};

View File

@@ -1,25 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

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