Implermented front page and set public

This commit is contained in:
Michel 2024-11-04 16:31:21 +01:00
parent 15a9dcf09b
commit d6ebf8f4ff
18 changed files with 658 additions and 23 deletions

View file

@ -0,0 +1,9 @@
#!/usr/bin/env sh
docker run \
--volume .:/app \
--rm \
--interactive \
--tty \
php:8.3-cli \
php /app/src/php/bin/doctrine.php orm:schema-tool:update --force

View file

@ -1,2 +1,193 @@
import '../../css/common/index.scss';
import '../common/index';
import DataTable, {Api} from "datatables.net-bs5";
import 'datatables.net-bs5/css/dataTables.bootstrap5.css';
const TABLE_AJAX_URL = '/api/dt/keys/available';
const GAME_GET_URL = '/api/web/game';
const CLAIM_URL = '/api/web/key/claim';
function getDetailsHTML(gameData: Game): string
{
let keyString = '';
gameData.keys.forEach(key => {
keyString += `
<div class="key input-group mb-2" data-key-id="${ key.id }">
<span class="input-group-text flex-grow-1">
<i class="${ key.store.icon } me-2"></i>
${ key.store.name }
<span class="text-muted ms-2">
provided by ${ key.list.owner }
</span>
</span>
<button class="btn btn-primary claim-btn">Claim</button>
</div>
`;
})
return `
<div class="row">
<div class="col-2">
<div class="ratio border-1 border-light-subtle rounded-3 bg-body-secondary" style="--bs-aspect-ratio: 161.803%">
<div>Image</div>
</div>
</div>
<div class="col-10">
<h2>
${ gameData.name }
</h2>
<div class="key-list">
${ keyString }
</div>
<div class="key-claim-display d-none">
<div class="input-group">
<span class="input-group-text store-display">
</span>
<span class="input-group-text flex-grow-1">
<span class="text-center key-display w-100">
#####-#####-#####
</span>
</span>
<a class="btn btn-primary open-claim-page-btn" target="_blank" rel="noopener noreferrer">Open Claim Page</a>
</div>
<div class="row">
<span class="col-6 text-center">
<span class="text-muted">provided by:</span> <br>
<span class="providedBy"></span>
</span>
<span class="col-6 text-center">
<span class="text-muted">key from: </span><br>
<span class="from"></span>
</span>
</div>
</div>
</div>
</div>
`;
}
async function renderDetails(childRow: HTMLDivElement, gameid: number) {
const loadingContainer = document.createElement('div');
loadingContainer.classList.add('m-3', 'mx-auto');
loadingContainer.style.width = '2rem';
const spinner = document.createElement('div');
spinner.classList.add('spinner-border');
loadingContainer.appendChild(spinner);
childRow.appendChild(loadingContainer);
const queryParams = new URLSearchParams();
queryParams.set('gameid', gameid.toString());
const response = await fetch(
GAME_GET_URL + '?' + queryParams.toString(),
)
if (!response.ok) {
throw new Error(response.statusText);
}
const gameData: Game = await response.json();
childRow.removeChild(loadingContainer);
const parser = new DOMParser();
const gameDisplay: HTMLDivElement|null = parser.parseFromString(getDetailsHTML(gameData), 'text/html').body.firstChild;
if (!gameDisplay) {
return;
}
gameDisplay.querySelectorAll('.claim-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const key = btn.closest('.key')?.dataset.keyId ?? 0;
const formData = new FormData();
formData.set('keyid', key);
const response = await fetch(CLAIM_URL, {
method: 'POST',
body: formData
})
if (!response.ok) {
throw new Error(response.statusText);
}
const data = await response.json();
gameDisplay.querySelector('.key-list')?.classList.add('d-none');
const claimDisplay: HTMLDivElement|null = gameDisplay.querySelector('.key-claim-display')
if (!claimDisplay) {
throw new Error("Can't find claim display...");
}
claimDisplay.classList.remove('d-none');
// @ts-expect-error
claimDisplay.querySelector('.store-display').textContent = data.store.name;
// @ts-expect-error
claimDisplay.querySelector('.key-display').textContent = data.key;
// @ts-expect-error
claimDisplay.querySelector('.providedBy').textContent = data.providedBy;
// @ts-expect-error
claimDisplay.querySelector('.from').textContent = data.from;
const claimBtn = claimDisplay.querySelector<HTMLAnchorElement>('.open-claim-page-btn');
if (!claimBtn) {
return;
}
if (data.store.claimLink === null) {
claimBtn.remove();
return;
}
claimBtn.href = data.store.claimLink;
})
})
childRow.appendChild(gameDisplay);
}
document.addEventListener('DOMContentLoaded', () => {
const keyTable = document.getElementById('keyTable');
if (!keyTable) {
return;
}
const table = new DataTable(keyTable, {
ajax: {
url: TABLE_AJAX_URL,
},
columns: [
{
data: 'name',
}
],
createdRow(row: Node, data: any) {
const tableRow = <HTMLTableRowElement>row;
tableRow.classList.add('cursor-pointer');
tableRow.addEventListener('click', () => {
const rowAPI = table.row(row);
if (rowAPI.child.isShown()) {
rowAPI.child.hide();
return;
}
const childRow = document.createElement('tr');
const cell = childRow.insertCell();
cell.colSpan = row.childNodes.length;
const cellContainer = document.createElement('div');
cell.appendChild(cellContainer);
rowAPI.child(childRow).show();
renderDetails(cellContainer, data.id);
})
},
})
})

View file

@ -6,6 +6,7 @@ import {getCurrentlySelectedList} from "./userlists";
const SEARCH_API_URL = '/api/web/share/search';
const ADD_API_URL = '/api/web/share/add';
const REMOVE_API_URL = '/api/web/share/remove';
const SET_PUBLIC_API_URL = '/api/web/share/setPublic';
const DT_API_URL = '/api/dt/list/users';
@ -44,6 +45,37 @@ function actionColumnRender(
}
export function initShare() {
const shareContainer = document.querySelector<HTMLDivElement>('.share-content');
if (!shareContainer) {
throw new Error("Missing share container");
}
const publicSwitch = document.querySelector<HTMLInputElement>('#public-switch');
if (!publicSwitch) {
throw new Error("Missing public switch");
}
shareContainer.classList.toggle('d-none', publicSwitch.checked);
publicSwitch.addEventListener('click', async () => {
shareContainer.classList.toggle('d-none', publicSwitch.checked);
const formData = new FormData();
formData.append('listid', getCurrentlySelectedList()?.toString() ?? '');
formData.append('publicState', publicSwitch.checked ? "1" : "0");
const response = await fetch(
SET_PUBLIC_API_URL,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
throw new Error(response.statusText);
}
})
const input = document.querySelector<HTMLInputElement>('#share-user-search');
if (!input) {
throw new Error("Missing search element");

21
src/js/pages/types/gameindex.d.ts vendored Normal file
View file

@ -0,0 +1,21 @@
type GameList = {
owner: string;
name: string;
}
type Store = {
name: string;
icon: string;
}
type Key = {
id: number;
store: Store;
fromWhere: string;
list: GameList;
}
type Game = {
name: string;
id: number;
keys: Key[];
}

View file

@ -11,7 +11,7 @@ use Symfony\Component\Uid\UuidV4;
#[ORM\Entity]
#[ORM\Table(name: "users")]
final class User
class User
{
#[ORM\Id()]

View file

@ -31,6 +31,9 @@ final class Key implements JsonSerializable
#[ORM\Column(type: 'integer', enumType: KeyState::class)]
private KeyState $state;
#[ORM\ManyToOne]
private User $claimedUser;
public function __construct(Game $game, GamesList $list, string $key, Store $store, ?string $storeLink, ?string $fromWhere)
{
$this->game = $game;
@ -52,11 +55,6 @@ final class Key implements JsonSerializable
return $this->game;
}
public function getContributedUser(): User
{
return $this->contributedUser;
}
public function getKey(): string
{
return $this->key;
@ -82,6 +80,19 @@ final class Key implements JsonSerializable
return $this->state;
}
public function getList(): GamesList
{
return $this->list;
}
public function setState(KeyState $state): void {
$this->state = $state;
}
public function setClaimedUser(User $claimedUser): void {
$this->claimedUser = $claimedUser;
}
public function jsonSerialize(): mixed
{
return [

View file

@ -12,4 +12,33 @@ enum Store: string
case UPLAY = 'uplay';
case BATTLENET = 'battlenet';
case EXTERNAL = 'external';
public function getName(): string
{
return match ($this) {
self::STEAM => 'Steam',
self::GOG => "GOG",
self::EPICGAMES => "Epic Games Store",
self::ORIGIN => "EA Play / Origin",
self::UPLAY => "UPlay",
self::BATTLENET => "Battlenet",
self::EXTERNAL => "Other",
};
}
public function getIcon(): string
{
return match ($this) {
self::STEAM => 'fa-solid fa-steam',
default => '',
};
}
public function getClaimURL(Key $key): ?string {
return match ($this) {
self::STEAM => 'https://store.steampowered.com/account/registerkey?key=' . $key->getKey(),
self::EXTERNAL => $key->getStoreLink(),
default => null,
};
}
}

View file

@ -10,7 +10,7 @@ use GamesShop\Entities\Account\User;
#[ORM\Entity]
#[ORM\Table(name: 'games_lists')]
final class GamesList
class GamesList
{
#[ORM\Id]
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
@ -28,6 +28,9 @@ final class GamesList
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $claimer;
#[ORM\Column(type: 'integer', options: ['unsigned' => true, 'default' => 0])]
private bool $isPublic = false;
/**
* @param User $owner
* @param string|null $name
@ -60,6 +63,14 @@ final class GamesList
return $this->claimer;
}
public function isPublic(): bool {
return $this->isPublic;
}
public function setIsPublic(bool $isPublic): void {
$this->isPublic = $isPublic;
}
public function addClaimer(User $claimer): void
{
$this->claimer[] = $claimer;

View file

@ -0,0 +1,74 @@
<?php
namespace GamesShop\Routing\Api\DataTables;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Criteria;
use Doctrine\DBAL\Query\Expression\ExpressionBuilder;
use Doctrine\ORM\EntityManager;
use GamesShop\ContainerHandler;
use GamesShop\Entities\Games\Key;
use GamesShop\Entities\Games\KeyState;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use GamesShop\UserManager;
use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class AvailableKeysEndpoint
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::VIEWER)) {
throw new ForbiddenException();
}
$applicableLists = ContainerHandler::get(UserManager::class)
->getApplicableGameLists($user);
$keyRepo = $this->entityManager->getRepository(Key::class);
$keys = $keyRepo->matching(Criteria::create()
->where(Criteria::expr()->in('list', $applicableLists->toArray()))
->andWhere(Criteria::expr()->eq('state', KeyState::AVAILABLE))
);
$games = new ArrayCollection();
foreach ($keys as $key) {
$game = $key->getGame();
if ($games->contains($game)) {
continue;
}
$games->add($game);
}
return new JsonResponse(
[
'data' =>
$games
->map(fn ($game) => [
'name' => $game->getName(),
'id' => $game->getId(),
])
->toArray()
]
);
}
}

View file

@ -12,6 +12,7 @@ final class DataTablesAPIRoutes
AccountsEndpoint::applyRoutes($group);
$group->get('/keys/provider', ProviderKeysEndpoint::class);
$group->get('/keys/available', AvailableKeysEndpoint::class);
$group->get('/list/users', SharedUsersEndpoint::class);
}
}

View file

@ -0,0 +1,71 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Games\Key;
use GamesShop\Entities\Games\KeyState;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class ClaimKey
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::VIEWER)) {
throw new ForbiddenException();
}
$body = $request->getParsedBody();
if (!array_key_exists('keyid', $body)) {
throw new BadRequestException();
}
$key = $this->entityManager->getRepository(Key::class)->find($body['keyid']);
if (!$key instanceof Key) {
throw new BadRequestException();
}
if (!$key->getList()->isPublic() && !$key->getList()->getClaimer()->contains($user)) {
throw new BadRequestException();
}
if ($key->getState() !== KeyState::AVAILABLE) {
throw new BadRequestException();
}
$key->setState(KeyState::CLAIMED);
$key->setClaimedUser($user);
$this->entityManager->flush();
return new JsonResponse([
'key' => $key->getKey(),
'providedBy' => $key->getList()->getOwner()->getName(),
'from' => $key->getFromWhere() ?? 'unknown',
'store' => [
'name' => $key->getStore()->getName(),
'icon' => $key->getStore()->getIcon(),
'claimLink' => $key->getStore()->getClaimURL($key),
],
]);
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManager;
use GamesShop\ContainerHandler;
use GamesShop\Entities\Games\Game;
use GamesShop\Entities\Games\Key;
use GamesShop\Entities\Games\KeyState;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use GamesShop\UserManager;
use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\NotFoundException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class GetGameData
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface {
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::VIEWER)) {
throw new ForbiddenException();
}
$game = $this->entityManager->getRepository(Game::class)
->find($request->getQueryParams()['gameid']);
if (!$game) {
throw new NotFoundException();
}
$applicableLists = ContainerHandler::get(UserManager::class)
->getApplicableGameLists($user);
$keyRepo = $this->entityManager->getRepository(Key::class);
$keys = $keyRepo->matching(Criteria::create()
->where(Criteria::expr()->in('list', $applicableLists->toArray()))
->andWhere(Criteria::expr()->eq('game', $game))
->andWhere(Criteria::expr()->eq('state', KeyState::AVAILABLE))
);
return new JsonResponse([
'name' => $game->getName(),
'id' => $game->getId(),
'keys' => $keys->map(fn (Key $key) => [
'id' => $key->getId(),
'store' => [
'name' => $key->getStore()->getName(),
'icon' => $key->getStore()->getIcon()
],
'fromWhere' => $key->getFromWhere(),
'list' => [
'owner' => $key->getList()->getOwner()->getName(),
'name' => $key->getList()->getName(),
],
])->toArray(),
]);
}
}

View file

@ -0,0 +1,53 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response;
use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class SetListPublic
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
)
{
}
public function __invoke(ServerRequestInterface $request): ResponseInterface
{
if (!$this->loginHandler->isLoggedIn()) {
throw new UnauthorizedException();
}
$user = $this->loginHandler->getCurrentUser();
if (!$user->getPermission()->hasLevel(UserPermission::PROVIDER)) {
throw new ForbiddenException();
}
$body = $request->getParsedBody();
if (!array_key_exists('listid', $body)) {
throw new BadRequestException();
}
$list = $this->entityManager->getRepository(GamesList::class)->findOneBy(['owner' => $user, 'id' => $body['listid']]);
if (!$list instanceof GamesList) {
throw new BadRequestException();
}
$list->setIsPublic($body['publicState'] === '1');
$this->entityManager->flush();
return new Response();
}
}

View file

@ -18,5 +18,9 @@ final class WebAPIRoutes
$group->get('/share/search', SearchForUsers::class);
$group->post('/share/add', AddUserToList::class);
$group->post('/share/remove', RemoveUserFromList::class);
$group->post('/share/setPublic', SetListPublic::class);
$group->get('/game', GetGameData::class);
$group->post('/key/claim', ClaimKey::class);
}
}

41
src/php/UserManager.php Normal file
View file

@ -0,0 +1,41 @@
<?php
namespace GamesShop;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
use GamesShop\Entities\GamesList;
class UserManager
{
public function __construct(
private readonly EntityManager $entityManager
)
{
}
public function getApplicableGameLists(User $user): Collection {
$listRepo = $this->entityManager->getRepository(GamesList::class);
$allLists = $listRepo->findAll();
$applicableLists = new ArrayCollection();
foreach ($allLists as $list) {
if ($list->isPublic()) {
$applicableLists->add($list);
continue;
}
if (!$list->getClaimer()->contains($user)) {
continue;
}
$applicableLists->add($list);
}
return $applicableLists;
}
}

View file

@ -41,7 +41,7 @@ $resource = $resources->getResource($resourceEntry);
<?= $this->section('modal') ?>
<div class="position-absolute bottom-0 start-50 opacity-25 text-center translate-middle-x">
<div class="position-fixed bottom-0 start-50 opacity-25 text-center translate-middle-x">
<span class="h1">PROTOTYPE / POC</span>
</div>

View file

@ -5,5 +5,11 @@ $this->layout('layout/main', [ 'resourceEntry' => 'index' ]);
?>
<h1>
Hello
Games
</h1>
<table id="keyTable" class="w-100 table table-striped">
<thead>
<th>Name</th>
</thead>
</table>

View file

@ -87,8 +87,7 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
<thead>
<tr>
<th>Column</th>
<th>Header</th>
<th>Attribute</th>
<th>Header</th> <th>Attribute</th>
</tr>
</thead>
<tbody></tbody>
@ -103,18 +102,25 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
Share your list
</h2>
<label for="share-user-search">Search for a user...</label>
<div class="input-group mb-3">
<input type="text" class="form-control" id="share-user-search" placeholder="">
<button class="btn btn-primary js--adds-user">+ Add</button>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="public-switch" <?= $list->isPublic() ? "checked" : "" ?>>
<label class="form-check-label" for="public-switch">Is public</label>
</div>
<h3>Users shared to</h3>
<table id="shared-users-table" class="table table-striped w-100">
<thead>
</thead>
<tbody></tbody>
</table>
<div class="share-content">
<label for="share-user-search">Search for a user...</label>
<div class="input-group mb-3">
<input type="text" class="form-control" id="share-user-search" placeholder="">
<button class="btn btn-primary js--adds-user">+ Add</button>
</div>
<h3>Users shared to</h3>
<table id="shared-users-table" class="table table-striped w-100">
<thead>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div>