First Commit

This commit is contained in:
Michel Fedde 2024-06-30 14:41:33 +02:00
commit 923d6ca242
35 changed files with 4933 additions and 0 deletions

View file

@ -0,0 +1,3 @@
@import "@fortawesome/fontawesome-free/scss/fontawesome";
@import "@fortawesome/fontawesome-free/scss/solid";
@import "@fortawesome/fontawesome-free/scss/brands";

13
src/css/common/index.scss Normal file
View file

@ -0,0 +1,13 @@
@import "bootstrap";
@import "fonts";
$main-container-max-width: 992px;
.navbar :first-child,
main {
width: 100%;
max-width: $main-container-max-width;
margin: 0 auto;
}

1
src/file-index.json Normal file
View file

@ -0,0 +1 @@
{"index":{"js":["js/runtime.js","js/index.js"],"css":"css/common.css"}}

1
src/js/common/index.ts Normal file
View file

@ -0,0 +1 @@
import "./theme";

39
src/js/common/theme.ts Normal file
View file

@ -0,0 +1,39 @@
(() => {
function setTheme (mode = 'auto') {
const userMode = localStorage.getItem('bs-theme');
const sysMode = window.matchMedia('(prefers-color-scheme: light)').matches;
const useSystem = mode === 'system' || (!userMode && mode === 'auto');
const modeChosen = selectMode(mode);
if (useSystem) {
localStorage.removeItem('bs-theme');
} else {
localStorage.setItem('bs-theme', modeChosen);
}
document.documentElement.setAttribute('data-bs-theme', useSystem ? (sysMode ? 'light' : 'dark') : modeChosen);
document.querySelectorAll('.mode-switch .btn').forEach(e => e.classList.remove('text-body'));
document.getElementById(modeChosen)?.classList.add('text-body');
}
function selectMode(mode: string): string {
const userMode = <string>localStorage.getItem('bs-theme');
const useSystem = mode === 'system' || (!userMode && mode === 'auto');
if (useSystem) {
return 'system';
}
if (mode === 'dark' || mode === 'light') {
return mode;
}
return userMode;
}
document.addEventListener('DOMContentLoaded', () => {
setTheme();
document.querySelectorAll('.mode-switch .btn').forEach(e => e.addEventListener('click', () => setTheme(e.id)));
window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', () => setTheme());
});
})()

2
src/js/pages/index.ts Normal file
View file

@ -0,0 +1,2 @@
import '../../css/common/index.scss';
import '../common/index';

View file

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace GamesShop;
use League\Container\Container;
use League\Container\ReflectionContainer;
final class ContainerHandler
{
private static Container|null $instance = null;
public static function getInstance(): Container
{
if (self::$instance === null) {
self::createInstance();
}
return self::$instance;
}
/**
* @template RequestedType
*
* @param class-string<RequestedType>|string $id
*
* @return RequestedType|mixed
*/
public static function get(string $id) {
return self::getInstance()->get($id);
}
private static function createInstance()
{
self::$instance = new Container();
self::$instance->delegate(new ReflectionContainer(true));
}
}

View file

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace GamesShop\Environment;
final readonly class DiscordEnvironment
{
public function __construct(
public string $clientId,
public string $clientSecret,
public string $loginUrl,
) {}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace GamesShop\Environment;
use DotenvVault\DotenvVault;
use GamesShop\Paths;
final class EnvironmentHandler
{
private const string ENVIRONMENT_PATH = Paths::ROOT_PATH . '/config';
public function load() {
$dotEnv = DotenvVault::createImmutable(
self::ENVIRONMENT_PATH
);
$dotEnv->safeLoad();
}
public function getDiscordEnvironment(): DiscordEnvironment {
return new DiscordEnvironment(
$_SERVER['DISCORD_CLIENT_ID'],
$_SERVER['DISCORD_CLIENT_SECRET'],
$_SERVER['DISCORD_CLIENT_LOGIN_URI'],
);
}
}

View file

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace GamesShop\Login;
final class LoginHandler
{
public function isLoggedIn(): bool {
if (session_status() !== PHP_SESSION_ACTIVE) {
session_start();
}
return isset($_SESSION['accountid']);
}
}

11
src/php/Paths.php Normal file
View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace GamesShop;
final class Paths
{
public const string ROOT_PATH = __DIR__ . '/../..';
public const string PUBLIC_PATH = self::ROOT_PATH . '/public';
public const string SOURCE_PATH = self::ROOT_PATH . '/src';
}

View file

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Psr\Http\Message\ResponseInterface;
final class ErrorRoute
{
public function renderErrorPage(int $errorCode): ResponseInterface {
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage('error', [ 'errorCode' => $errorCode ]);
$response = new Response;
$response->getBody()->write($pageContent);
return $response;
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\RedirectResponse;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class IndexRoute
{
public function __invoke(ServerRequestInterface $request): ResponseInterface {
$loginHandler = ContainerHandler::get(LoginHandler::class);
if (!$loginHandler->isLoggedIn()) {
return new RedirectResponse('/login');
}
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage('index');
$response = new Response;
$response->getBody()->write($pageContent);
return $response;
}
public static function applyRoutes(\League\Route\Router $router): void {
$router->get('/', self::class);
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Psr\Http\Message\ServerRequestInterface;
final class LoginRoutes
{
public function login(ServerRequestInterface $request) {
$discordEnv = ContainerHandler::get(EnvironmentHandler::class)->getDiscordEnvironment();
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderPage(
'login',
[
'discordUrl' => $discordEnv->loginUrl
]
);
$response = new Response;
$response->getBody()->write($pageContent);
return $response;
}
public static function addRoutes(\League\Route\Router $router): void {
$routes = ContainerHandler::get(LoginRoutes::class);
$router->get('/login', $routes->login(...));
}
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Paths;
use Laminas\Diactoros\Response;
use Mimey\MimeTypes;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class ResourceRoute
{
private const array RESOURCE_EXTENSIONS = [
'js',
'css',
'ttf', 'woff', 'woff2',
'gif', 'svg', 'png', 'jpg'
];
public function __invoke(ServerRequestInterface $request, array $args): ResponseInterface {
$filePath = Paths::PUBLIC_PATH . $request->getUri()->getPath();
if (!file_exists($filePath)) {
$response = new Response(status: 404);
$response->getBody()->write('File not found');
return $response;
}
$mimey = ContainerHandler::get(MimeTypes::class);
$response = new Response(
headers: [
'Content-Type' => $mimey->getMimeType(pathinfo($filePath, PATHINFO_EXTENSION)),
'Cache-Control' => 'public, max-age=3600, must-revalidate',
]
);
$response->getBody()->write(file_get_contents($filePath));
return $response;
}
public static function addRouteEntry(\League\Route\Router $router): void {
$joinedResourceExtensions = implode('|', self::RESOURCE_EXTENSIONS);
$router->addPatternMatcher('resource', ".+[{$joinedResourceExtensions}]");
$router->get('/{resource:resource}', self::class);
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace GamesShop\Routing;
use GamesShop\ContainerHandler;
use GamesShop\Login\LoginHandler;
use GamesShop\Templates\TemplateEngine;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\ServerRequestFactory;
use League\Container\Container;
use League\Route\Strategy\ApplicationStrategy;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final class Router
{
public function route()
{
$request = ServerRequestFactory::fromGlobals(
$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
);
$router = new \League\Route\Router;
$strategy = (new ApplicationStrategy)->setContainer(ContainerHandler::getInstance());
$router->setStrategy($strategy);
IndexRoute::applyRoutes($router);
LoginRoutes::addRoutes($router);
ResourceRoute::addRouteEntry($router);
try {
return $router->dispatch($request);
} catch (\League\Route\Http\Exception\NotFoundException $e) {
return (new ErrorRoute())->renderErrorPage(404);
}
}
}

View file

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace GamesShop\Templates;
final class ResourceEntry
{
/**
* @param string[] $js
* @param string[] $css
*/
public function __construct(
public readonly array $js,
public readonly array $css,
) { }
}

View file

@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace GamesShop\Templates;
use Exception;
use GamesShop\Paths;
final class ResourceIndex
{
private const string PATH = Paths::SOURCE_PATH . '/file-index.json';
/**
* @var ResourceEntry[]
*/
private array $resources = [];
public function __construct()
{
$fileContents = file_get_contents(self::PATH);
$index = json_decode($fileContents, true);
foreach ($index as $entryKey => $resource) {
$js = $resource['js'];
if (is_string($js)) {
$js = [$js];
}
$css = $resource['css'];
if (is_string($css)) {
$css = [$css];
}
$this->resources[$entryKey] = new ResourceEntry(
$js, $css
);
}
}
/**
* @throws Exception
*/
public function getResource(string $entry): ResourceEntry
{
if (!array_key_exists($entry, $this->resources)) {
throw new Exception("Entry '$entry' not found");
}
return $this->resources[$entry];
}
}

View file

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace GamesShop\Templates;
use GamesShop\Paths;
use League\Plates\Engine;
final class TemplateEngine extends Engine
{
private const string TEMPLATES_PATH = Paths::SOURCE_PATH . '/templates';
public function __construct(
private ResourceIndex $resourceIndex,
)
{
parent::__construct(self::TEMPLATES_PATH, 'php');
$this->addData([
'resources' => $this->resourceIndex,
]);
}
public function renderPage(string $page, array $data = array())
{
return parent::render("pages/$page", $data);
}
}

22
src/php/index.dev.php Normal file
View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
use GamesShop\ContainerHandler;
use GamesShop\Environment\EnvironmentHandler;
use GamesShop\Routing\Router;
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
use Whoops\Handler\PrettyPageHandler;
use Whoops\Run;
require_once __DIR__ . '/../vendor/autoload.php';
ContainerHandler::get(EnvironmentHandler::class)->load();
$whoops = new Run();
$whoops->pushHandler(new PrettyPageHandler);
$whoops->register();
$router = ContainerHandler::getInstance()->get(Router::class);
$result = $router->route();
(new SapiEmitter)->emit($result);

2
src/php/index.prod.php Normal file
View file

@ -0,0 +1,2 @@
<?php
declare(strict_types=1);

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
use GamesShop\Templates\ResourceIndex;
use League\Plates\Template\Template;
assert($this instanceof Template);
/** @var ResourceIndex $resources */
assert($resources instanceof ResourceIndex);
if (!isset($resourceEntry)) {
throw new Exception("Resource entry not set");
}
/**
* @var string $resourceEntry
*/
$resource = $resources->getResource($resourceEntry);
?>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Game Shop</title>
<?php foreach ($resource->js as $js): ?>
<script src="/<?= $js ?>"></script>
<?php endforeach; ?>
<?php foreach ($resource->css as $css): ?>
<link rel="stylesheet" href="/<?= $css ?>">
<?php endforeach; ?>
</head>
<body class="vh-100 d-flex flex-column">
<nav class="navbar navbar-expand-lg bg-body-tertiary">
<div class="container-fluid">
<a class="navbar-brand" href="/">Game Shop</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-content" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-content">
<ul class="navbar-nav me-auto mb-2 mb-lg-0"></ul>
<div class="d-flex mode-switch">
<button title="Use dark mode" id="dark" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-moon"></i>
</button>
<button title="Use light mode" id="light" class="btn btn-sm btn-default text-secondary">
<i class="fa-regular fa-sun"></i>
</button>
<button title="Use system preferred mode" id="system" class="btn btn-sm btn-default text-secondary">
<i class="fa-solid fa-display"></i>
</button>
</div>
</div>
</div>
</nav>
<main class="mt-2 position-relative flex-grow-1">
<?= $this->section('content'); ?>
</main>
</body>
</html>

View file

@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
$this->layout('layout/main', [ 'resourceEntry' => 'index' ]);
/**
* @var int $errorCode
*/
?>
<img src="https://http.dog/<?= $errorCode ?>.jpg" alt="Error <?= $errorCode ?>" class="position-absolute top-50 start-50 translate-middle w-100" style="max-width: 800px" />

View file

@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
$this->layout('layout/main', [ 'resourceEntry' => 'index' ]);
?>
<h1>
Hello
</h1>

View file

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
$this->layout('layout/main', [ 'resourceEntry' => 'index' ]);
/** @var string $discordUrl */
?>
<a href="<?= $discordUrl ?>">
<button class="btn btn-primary position-absolute top-50 start-50 translate-middle">
<i class="fa-brands fa-discord"></i>
Login with Discord
</button>
</a>