initial commit
This commit is contained in:
116
internal/view/component/footer/footer.templ
Normal file
116
internal/view/component/footer/footer.templ
Normal file
@@ -0,0 +1,116 @@
|
||||
package footer
|
||||
|
||||
type FooterItem struct {
|
||||
name string
|
||||
href string
|
||||
}
|
||||
|
||||
// Specify the links to show in the footer
|
||||
func getFooterItems() []FooterItem {
|
||||
return []FooterItem{
|
||||
{
|
||||
name: "About",
|
||||
href: "/about",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the template fragment for the Footer
|
||||
templ Footer() {
|
||||
<footer class="bg-mantle mt-10">
|
||||
<div
|
||||
class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8"
|
||||
>
|
||||
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
|
||||
<a
|
||||
class="inline-block rounded-full bg-teal p-2 text-crust
|
||||
shadow-sm transition hover:bg-teal/75"
|
||||
href="#main-content"
|
||||
>
|
||||
<span class="sr-only">Back to top</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="size-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293
|
||||
3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4
|
||||
4a1 1 0 010 1.414z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div class="lg:flex lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<div class="flex justify-center text-text lg:justify-start">
|
||||
// TODO: logo/branding here
|
||||
<span class="text-2xl">OSL Stats</span>
|
||||
</div>
|
||||
<p
|
||||
class="mx-auto max-w-md text-center leading-relaxed
|
||||
text-subtext0"
|
||||
>placeholder text</p>
|
||||
</div>
|
||||
<ul
|
||||
class="mt-12 flex flex-wrap justify-center gap-6 md:gap-8
|
||||
lg:mt-0 lg:justify-end lg:gap-12"
|
||||
>
|
||||
for _, item := range getFooterItems() {
|
||||
<li>
|
||||
<a
|
||||
class="transition hover:text-subtext1"
|
||||
href={ templ.SafeURL(item.href) }
|
||||
>{ item.name }</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="lg:flex lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p class="mt-4 text-center text-sm text-overlay0">
|
||||
by Haelnorr | placeholder text
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mt-2 text-center">
|
||||
<label
|
||||
for="theme-select"
|
||||
class="hidden lg:inline"
|
||||
>Theme</label>
|
||||
<select
|
||||
name="ThemeSelect"
|
||||
id="theme-select"
|
||||
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
|
||||
x-model="theme"
|
||||
>
|
||||
<template
|
||||
x-for="themeopt in [
|
||||
'dark',
|
||||
'light',
|
||||
'system',
|
||||
]"
|
||||
>
|
||||
<option
|
||||
x-text="displayThemeName(themeopt)"
|
||||
:value="themeopt"
|
||||
:selected="theme === themeopt"
|
||||
></option>
|
||||
</template>
|
||||
</select>
|
||||
<script>
|
||||
const displayThemeName = (value) => {
|
||||
if (value === "dark") return "Dark (Mocha)";
|
||||
if (value === "light") return "Light (Latte)";
|
||||
if (value === "system") return "System";
|
||||
};
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
}
|
||||
36
internal/view/component/nav/navbar.templ
Normal file
36
internal/view/component/nav/navbar.templ
Normal file
@@ -0,0 +1,36 @@
|
||||
package nav
|
||||
|
||||
type NavItem struct {
|
||||
name string // Label to display
|
||||
href string // Link reference
|
||||
}
|
||||
|
||||
// Return the list of navbar links
|
||||
func getNavItems() []NavItem {
|
||||
return []NavItem{}
|
||||
}
|
||||
|
||||
// Returns the navbar template fragment
|
||||
templ Navbar() {
|
||||
{{ navItems := getNavItems() }}
|
||||
<div x-data="{ open: false }">
|
||||
<header class="bg-crust">
|
||||
<div
|
||||
class="mx-auto flex h-16 max-w-7xl items-center gap-8
|
||||
px-4 sm:px-6 lg:px-8"
|
||||
>
|
||||
<a class="block" href="/">
|
||||
<!-- logo here -->
|
||||
<span class="text-3xl font-bold transition hover:text-green">
|
||||
OSL Stats
|
||||
</span>
|
||||
</a>
|
||||
<div class="flex flex-1 items-center justify-end sm:justify-between">
|
||||
@navLeft(navItems)
|
||||
@navRight()
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@sideNav(navItems)
|
||||
</div>
|
||||
}
|
||||
19
internal/view/component/nav/navbarleft.templ
Normal file
19
internal/view/component/nav/navbarleft.templ
Normal file
@@ -0,0 +1,19 @@
|
||||
package nav
|
||||
|
||||
// Returns the left portion of the navbar
|
||||
templ navLeft(navItems []NavItem) {
|
||||
<nav aria-label="Global" class="hidden sm:block">
|
||||
<ul class="flex items-center gap-6 text-xl">
|
||||
for _, item := range navItems {
|
||||
<li>
|
||||
<a
|
||||
class="text-subtext1 hover:text-green transition"
|
||||
href={ templ.SafeURL(item.href) }
|
||||
>
|
||||
{ item.name }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</nav>
|
||||
}
|
||||
115
internal/view/component/nav/navbarright.templ
Normal file
115
internal/view/component/nav/navbarright.templ
Normal file
@@ -0,0 +1,115 @@
|
||||
package nav
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||
|
||||
type ProfileItem struct {
|
||||
name string // Label to display
|
||||
href string // Link reference
|
||||
}
|
||||
|
||||
// Return the list of profile links
|
||||
func getProfileItems() []ProfileItem {
|
||||
return []ProfileItem{
|
||||
{
|
||||
name: "Profile",
|
||||
href: "/profile",
|
||||
},
|
||||
{
|
||||
name: "Account",
|
||||
href: "/account",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the right portion of the navbar
|
||||
templ navRight() {
|
||||
{{ user := contexts.CurrentUser(ctx) }}
|
||||
{{ items := getProfileItems() }}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="sm:flex sm:gap-2">
|
||||
if user != nil {
|
||||
<div x-data="{ isActive: false }" class="relative">
|
||||
<div
|
||||
class="inline-flex items-center overflow-hidden
|
||||
rounded-lg bg-sapphire hover:bg-sapphire/75 transition"
|
||||
>
|
||||
<button
|
||||
x-on:click="isActive = !isActive"
|
||||
class="h-full py-2 px-4 text-mantle hover:cursor-pointer"
|
||||
>
|
||||
<span class="sr-only">Profile</span>
|
||||
{ user.Username }
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="absolute end-0 z-10 mt-2 w-36 divide-y
|
||||
divide-surface2 rounded-lg border border-surface1
|
||||
bg-surface0 shadow-lg"
|
||||
role="menu"
|
||||
x-cloak
|
||||
x-transition
|
||||
x-show="isActive"
|
||||
x-on:click.away="isActive = false"
|
||||
x-on:keydown.escape.window="isActive = false"
|
||||
>
|
||||
<div class="p-2">
|
||||
for _, item := range items {
|
||||
<a
|
||||
href={ templ.SafeURL(item.href) }
|
||||
class="block rounded-lg px-4 py-2 text-md
|
||||
hover:bg-crust"
|
||||
role="menuitem"
|
||||
>
|
||||
{ item.name }
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<form hx-post="/logout">
|
||||
<button
|
||||
type="submit"
|
||||
class="flex w-full items-center gap-2
|
||||
rounded-lg px-4 py-2 text-md text-red
|
||||
hover:bg-red/25 hover:cursor-pointer"
|
||||
role="menuitem"
|
||||
@click="isActive=false"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
} else {
|
||||
<a
|
||||
class="hidden rounded-lg px-4 py-2 sm:block
|
||||
bg-green hover:bg-green/75 text-mantle transition"
|
||||
href="/login"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
@click="open = !open"
|
||||
class="block rounded-lg p-2.5 sm:hidden transition
|
||||
bg-surface0 text-subtext0 hover:text-overlay2/75"
|
||||
>
|
||||
<span class="sr-only">Toggle menu</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="size-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
45
internal/view/component/nav/sidenav.templ
Normal file
45
internal/view/component/nav/sidenav.templ
Normal file
@@ -0,0 +1,45 @@
|
||||
package nav
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||
|
||||
// Returns the mobile version of the navbar thats only visible when activated
|
||||
templ sideNav(navItems []NavItem) {
|
||||
{{ user := contexts.CurrentUser(ctx) }}
|
||||
<div
|
||||
x-show="open"
|
||||
x-transition
|
||||
class="absolute w-full bg-mantle sm:hidden z-10"
|
||||
>
|
||||
<div class="px-4 py-6">
|
||||
<ul class="space-y-1">
|
||||
for _, item := range navItems {
|
||||
<li>
|
||||
<a
|
||||
href={ templ.SafeURL(item.href) }
|
||||
class="block rounded-lg px-4 py-2 text-lg
|
||||
bg-surface0 text-text transition hover:bg-surface2"
|
||||
>
|
||||
{ item.name }
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
if user == nil {
|
||||
<div class="px-4 pb-6">
|
||||
<ul class="space-y-1">
|
||||
<li class="flex justify-center items-center gap-2">
|
||||
<a
|
||||
class="w-26 px-4 py-2 rounded-lg
|
||||
bg-green text-mantle transition hover:bg-green/75
|
||||
text-center"
|
||||
href="/login"
|
||||
>
|
||||
Login
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
63
internal/view/component/popup/error500Popup.templ
Normal file
63
internal/view/component/popup/error500Popup.templ
Normal file
@@ -0,0 +1,63 @@
|
||||
package popup
|
||||
|
||||
templ Error500Popup() {
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showError500"
|
||||
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
|
||||
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-x-[100%]"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="opacity-0 duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-[100%]"
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
class="rounded-sm bg-dark-red p-4"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2 text-red w-fit">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
|
||||
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
|
||||
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
|
||||
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
|
||||
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<strong class="block font-medium">Something went wrong </strong>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6 text-subtext0 hover:cursor-pointer"
|
||||
@click="showError500=false"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-red">
|
||||
An error occured on the server. Please try again later,
|
||||
or contact an administrator
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
63
internal/view/component/popup/error503Popup.templ
Normal file
63
internal/view/component/popup/error503Popup.templ
Normal file
@@ -0,0 +1,63 @@
|
||||
package popup
|
||||
|
||||
templ Error503Popup() {
|
||||
<div
|
||||
x-cloak
|
||||
x-show="showError503"
|
||||
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
|
||||
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
|
||||
x-transition:enter-start="opacity-0 translate-x-[100%]"
|
||||
x-transition:enter-end="opacity-100 translate-x-0"
|
||||
x-transition:leave="opacity-0 duration-200"
|
||||
x-transition:leave-start="opacity-100 translate-x-0"
|
||||
x-transition:leave-end="opacity-0 translate-x-[100%]"
|
||||
>
|
||||
<div
|
||||
role="alert"
|
||||
class="rounded-sm bg-dark-red p-4"
|
||||
>
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2 text-red w-fit">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="size-5"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
|
||||
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
|
||||
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
|
||||
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
|
||||
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<strong class="block font-medium">Service Unavailable</strong>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-6 text-subtext0 hover:cursor-pointer"
|
||||
@click="showError503=false"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-red">
|
||||
The service is currently available. It could be down for maintenance.
|
||||
Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
97
internal/view/layout/global.templ
Normal file
97
internal/view/layout/global.templ
Normal file
@@ -0,0 +1,97 @@
|
||||
package layout
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/popup"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/nav"
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/component/footer"
|
||||
|
||||
// Global page layout. Includes HTML document settings, header tags
|
||||
// navbar and footer
|
||||
templ Global(title string) {
|
||||
<!DOCTYPE html>
|
||||
<html
|
||||
lang="en"
|
||||
x-data="{
|
||||
theme: localStorage.getItem('theme')
|
||||
|| 'system'}"
|
||||
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
|
||||
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
|
||||
>
|
||||
<head>
|
||||
<!-- <script src="/static/js/theme.js"></script> -->
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>{ title }</title>
|
||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet"/>
|
||||
<link href="/static/css/output.css" rel="stylesheet"/>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||
<script>
|
||||
// uncomment this line to enable logging of htmx events
|
||||
// htmx.logAll();
|
||||
</script>
|
||||
<script>
|
||||
const bodyData = {
|
||||
showError500: false,
|
||||
showError503: false,
|
||||
showConfirmPasswordModal: false,
|
||||
handleHtmxBeforeOnLoad(event) {
|
||||
const requestPath = event.detail.pathInfo.requestPath;
|
||||
if (requestPath === "/reauthenticate") {
|
||||
// handle password incorrect on refresh attempt
|
||||
if (event.detail.xhr.status === 445) {
|
||||
event.detail.shouldSwap = true;
|
||||
event.detail.isError = false;
|
||||
} else if (event.detail.xhr.status === 200) {
|
||||
this.showConfirmPasswordModal = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
// handle errors from the server on HTMX requests
|
||||
handleHtmxError(event) {
|
||||
const errorCode = event.detail.errorInfo.error;
|
||||
|
||||
// internal server error
|
||||
if (errorCode.includes("Code 500")) {
|
||||
this.showError500 = true;
|
||||
setTimeout(() => (this.showError500 = false), 6000);
|
||||
}
|
||||
// service not available error
|
||||
if (errorCode.includes("Code 503")) {
|
||||
this.showError503 = true;
|
||||
setTimeout(() => (this.showError503 = false), 6000);
|
||||
}
|
||||
|
||||
// user is authorized but needs to refresh their login
|
||||
if (errorCode.includes("Code 444")) {
|
||||
this.showConfirmPasswordModal = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body
|
||||
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
|
||||
x-data="bodyData"
|
||||
x-on:htmx:error="handleHtmxError($event)"
|
||||
x-on:htmx:before-on-load="handleHtmxBeforeOnLoad($event)"
|
||||
>
|
||||
@popup.Error500Popup()
|
||||
@popup.Error503Popup()
|
||||
<div
|
||||
id="main-content"
|
||||
class="flex flex-col h-screen justify-between"
|
||||
>
|
||||
@nav.Navbar()
|
||||
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
||||
{ children... }
|
||||
</div>
|
||||
@footer.Footer()
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
34
internal/view/page/error.templ
Normal file
34
internal/view/page/error.templ
Normal file
@@ -0,0 +1,34 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
import "strconv"
|
||||
|
||||
// Page template for Error pages. Error code should be a HTTP status code as
|
||||
// a string, and err should be the corresponding response title.
|
||||
// Message is a custom error message displayed below the code and error.
|
||||
templ Error(code int, err string, message string) {
|
||||
@layout.Global(err) {
|
||||
<div
|
||||
class="grid mt-24 left-0 right-0 top-0 bottom-0
|
||||
place-content-center bg-base px-4"
|
||||
>
|
||||
<div class="text-center">
|
||||
<h1
|
||||
class="text-9xl text-text"
|
||||
>{ strconv.Itoa(code) }</h1>
|
||||
<p
|
||||
class="text-2xl font-bold tracking-tight text-subtext1
|
||||
sm:text-4xl"
|
||||
>{ err }</p>
|
||||
<p
|
||||
class="mt-4 text-subtext0"
|
||||
>{ message }</p>
|
||||
<a
|
||||
href="/"
|
||||
class="mt-6 inline-block rounded-lg bg-mauve px-5 py-3
|
||||
text-sm text-crust transition hover:bg-mauve/75"
|
||||
>Go to homepage</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
13
internal/view/page/index.templ
Normal file
13
internal/view/page/index.templ
Normal file
@@ -0,0 +1,13 @@
|
||||
package page
|
||||
|
||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||
|
||||
// Page content for the index page
|
||||
templ Index() {
|
||||
@layout.Global("OSL Stats") {
|
||||
<div class="text-center mt-24">
|
||||
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
||||
<div>Placeholder text</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user