Merge pull request #10 from bdfin/add-about-page

Add about page
This commit is contained in:
2024-03-14 12:26:59 +00:00
committed by GitHub
14 changed files with 146 additions and 89 deletions

View File

@@ -3,7 +3,7 @@
<div class="flex flex-col min-h-screen fade-in"> <div class="flex flex-col min-h-screen fade-in">
<NavBar/> <NavBar/>
<div class="flex-1 px-4 md:px-12 py-4"> <div class="flex-1 px-4 md:px-12 lg:px-24 xl:px-32 py-4">
@Body @Body
</div> </div>

View File

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

View File

@@ -1,7 +1,5 @@
@page "/" @page "/"
@implements IDisposable
@inject IJSRuntime JSRuntime @inject IJSRuntime JSRuntime
<PageTitle>Home - Beau Findlay</PageTitle> <PageTitle>Home - Beau Findlay</PageTitle>
@@ -12,19 +10,19 @@
<Typewriter Text="Hi, I'm Beau."/> <Typewriter Text="Hi, I'm Beau."/>
</h1> </h1>
<p class="text-xl mt-4"> <p class="text-xl py-4">
<Typewriter Name="@TypewriterConstants.Name.IntroComplete" Text="I'm a UK-based software engineer and I love building cool stuff."/> <Typewriter Name="@TypewriterConstants.Name.IntroComplete" Text="I'm a UK-based software engineer and I love building cool stuff."/>
</p> </p>
<h2 class="text-2xl mt-16 font-semibold"> <h2 class="text-2xl pt-16 font-semibold">
<Typewriter Text="A bit about me"/> <Typewriter Text="A bit about me"/>
</h2> </h2>
<p class="text-xl mt-4"> <p class="text-xl py-4">
<Typewriter Text="I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users."/> <Typewriter Text="I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users."/>
</p> </p>
<p class="text-xl mt-4"> <p class="text-xl py-4">
<Typewriter Text="I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called un:hurd."/> <Typewriter Text="I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called un:hurd."/>
</p> </p>
} }
@@ -32,13 +30,13 @@ else
{ {
<h1 class="text-4xl">Hi, I'm Beau.</h1> <h1 class="text-4xl">Hi, I'm Beau.</h1>
<p class="text-xl mt-4">I'm a UK-based software engineer and I love building cool stuff.</p> <p class="text-xl py-4">I'm a UK-based software engineer and I love building cool stuff.</p>
<h2 class="text-3xl mt-16 font-semibold">A bit about me</h2> <h2 class="text-3xl pt-16 font-semibold">A bit about me</h2>
<p class="text-xl mt-4">I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users.</p> <p class="text-xl py-4">I mostly specialise in back-end C#/.NET development and I've built systems that scale for hundreds-of-thousands of global users.</p>
<p class="text-xl mt-4">I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called <Anchor Href="https://unhurd.co.uk">un:hurd</Anchor>.</p> <p class="text-xl py-4">I've worked with businesses at all sizes and stages and I'm currently heading up the tech as CTO at a cool startup called <Anchor Href="https://unhurd.co.uk">un:hurd</Anchor>.</p>
} }
@@ -50,8 +48,6 @@ else
{ {
if (firstRender) if (firstRender)
{ {
Typewriter.OnAllTypingCompleted += HandleTypingCompleted;
var renderedBeforeAsString = await JSRuntime.InvokeAsync<string>("localStorage.getItem", ComponentKey); var renderedBeforeAsString = await JSRuntime.InvokeAsync<string>("localStorage.getItem", ComponentKey);
var previousValue = hasPreviouslyRendered; var previousValue = hasPreviouslyRendered;
@@ -69,14 +65,4 @@ else
} }
} }
private static void HandleTypingCompleted()
{
Console.WriteLine("Typewriter finished typing.");
}
public void Dispose()
{
Typewriter.OnAllTypingCompleted -= HandleTypingCompleted;
}
} }

View File

@@ -2,7 +2,11 @@
module.exports = { module.exports = {
content: ["./**/*.{razor,html,cshtml}"], content: ["./**/*.{razor,html,cshtml}"],
theme: { theme: {
extend: {}, extend: {
fontFamily: {
cascadia: ["Cascadia Code", "mono-space"]
}
},
}, },
plugins: [], plugins: [],
} }

View File

@@ -2,6 +2,11 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@font-face {
font-family: "Cascadia Code";
src: url("../fonts/CascadiaCode.woff2");
}
@keyframes blink { @keyframes blink {
from, to { opacity: 1 } from, to { opacity: 1 }
50% { opacity: 0 } 50% { opacity: 0 }

View File

@@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*/ */
/* /*
@@ -32,11 +32,9 @@
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default. 6. Use the user's configured `sans` font-variation-settings by default.
7. Disable tap highlights on iOS
*/ */
html, html {
:host {
line-height: 1.5; line-height: 1.5;
/* 1 */ /* 1 */
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@@ -46,14 +44,12 @@ html,
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal; font-variation-settings: normal;
/* 6 */ /* 6 */
-webkit-tap-highlight-color: transparent;
/* 7 */
} }
/* /*
@@ -125,10 +121,8 @@ strong {
} }
/* /*
1. Use the user's configured `mono` font-family by default. 1. Use the user's configured `mono` font family by default.
2. Use the user's configured `mono` font-feature-settings by default. 2. Correct the odd `em` font sizing in all browsers.
3. Use the user's configured `mono` font-variation-settings by default.
4. Correct the odd `em` font sizing in all browsers.
*/ */
code, code,
@@ -137,12 +131,8 @@ samp,
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-feature-settings: normal;
/* 2 */
font-variation-settings: normal;
/* 3 */
font-size: 1em; font-size: 1em;
/* 4 */ /* 2 */
} }
/* /*
@@ -569,11 +559,6 @@ video {
margin-right: auto; margin-right: auto;
} }
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.ml-3 { .ml-3 {
margin-left: 0.75rem; margin-left: 0.75rem;
} }
@@ -638,6 +623,10 @@ video {
height: 100%; height: 100%;
} }
.h-7 {
height: 1.75rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
@@ -658,6 +647,10 @@ video {
width: 2rem; width: 2rem;
} }
.w-auto {
width: auto;
}
.w-full { .w-full {
width: 100%; width: 100%;
} }
@@ -688,6 +681,10 @@ video {
cursor: pointer; cursor: pointer;
} }
.list-disc {
list-style-type: disc;
}
.grid-cols-1 { .grid-cols-1 {
grid-template-columns: repeat(1, minmax(0, 1fr)); grid-template-columns: repeat(1, minmax(0, 1fr));
} }
@@ -799,30 +796,50 @@ video {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.py-8 {
padding-top: 2rem;
padding-bottom: 2rem;
}
.py-6 { .py-6 {
padding-top: 1.5rem; padding-top: 1.5rem;
padding-bottom: 1.5rem; padding-bottom: 1.5rem;
} }
.py-8 { .pb-2 {
padding-top: 2rem; padding-bottom: 0.5rem;
padding-bottom: 2rem;
} }
.pb-4 { .pb-4 {
padding-bottom: 1rem; padding-bottom: 1rem;
} }
.pb-8 {
padding-bottom: 2rem;
}
.pl-8 {
padding-left: 2rem;
}
.pt-4 {
padding-top: 1rem;
}
.pt-8 { .pt-8 {
padding-top: 2rem; padding-top: 2rem;
} }
.pt-16 {
padding-top: 4rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
.font-mono { .font-cascadia {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: Cascadia Code, mono-space;
} }
.text-2xl { .text-2xl {
@@ -935,10 +952,6 @@ video {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.outline {
outline-style: solid;
}
.ring-1 { .ring-1 {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
@@ -954,6 +967,12 @@ video {
--tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity)); --tw-ring-color: rgb(209 213 219 / var(--tw-ring-opacity));
} }
@font-face {
font-family: "Cascadia Code";
src: url("../fonts/CascadiaCode.woff2");
}
@keyframes blink { @keyframes blink {
from, to { from, to {
opacity: 1 opacity: 1
@@ -1061,6 +1080,17 @@ body::-webkit-scrollbar-thumb {
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
} }
@media (prefers-color-scheme: dark) {
.dark\:fill-gray-300 {
fill: #d1d5db;
}
.dark\:text-gray-600 {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity));
}
}
@media (min-width: 640px) { @media (min-width: 640px) {
.sm\:col-span-2 { .sm\:col-span-2 {
grid-column: span 2 / span 2; grid-column: span 2 / span 2;
@@ -1102,13 +1132,16 @@ body::-webkit-scrollbar-thumb {
} }
} }
@media (prefers-color-scheme: dark) { @media (min-width: 1024px) {
.dark\:fill-gray-300 { .lg\:px-24 {
fill: #d1d5db; padding-left: 6rem;
} padding-right: 6rem;
}
.dark\:text-gray-600 { }
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity)); @media (min-width: 1280px) {
.xl\:px-32 {
padding-left: 8rem;
padding-right: 8rem;
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 862 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -5,6 +5,14 @@
<script id="cookieyes" <script id="cookieyes"
type="text/javascript" type="text/javascript"
src="https://cdn-cookieyes.com/client_data/a05e8ecc917e725a2226b46a/script.js"></script> 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 charset="utf-8"/>
<meta name="viewport" <meta name="viewport"
content="width=device-width, initial-scale=1.0"/> content="width=device-width, initial-scale=1.0"/>
@@ -17,8 +25,7 @@
<title>Beau Findlay</title> <title>Beau Findlay</title>
<base href="/"/> <base href="/"/>
<link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png"> <link rel="apple-touch-icon" sizes="180x180" href="images/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="images/favicon-32x32.png"> <link rel="icon" type="image/png" href="images/logo.png">
<link rel="icon" type="image/png" sizes="16x16" href="images/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest"> <link rel="manifest" href="/site.webmanifest">
<link rel="stylesheet" <link rel="stylesheet"
href="css/app.min.css"/> href="css/app.min.css"/>
@@ -29,7 +36,7 @@
referrerpolicy="no-referrer"/> referrerpolicy="no-referrer"/>
</head> </head>
<body class="bg-black font-mono text-slate-50 min-h-screen antialiased"> <body class="bg-black font-cascadia text-slate-50 min-h-screen antialiased">
<div id="app" <div id="app"
class="h-full"> class="h-full">
<div class="flex items-center justify-center text-2xl"> <div class="flex items-center justify-center text-2xl">