Adds share feature

This commit is contained in:
Michel 2024-10-31 15:34:57 +01:00
parent 287c1f67c5
commit 15a9dcf09b
14 changed files with 379 additions and 23 deletions

7
package-lock.json generated
View file

@ -12,6 +12,7 @@
"@fortawesome/fontawesome-free": "^6.5.2",
"@types/bootstrap": "^5.2.10",
"bootstrap": "^5.3.3",
"bootstrap5-autocomplete": "^1.1.33",
"datatables.net-bs5": "^2.0.8"
},
"devDependencies": {
@ -699,6 +700,12 @@
"@popperjs/core": "^2.11.8"
}
},
"node_modules/bootstrap5-autocomplete": {
"version": "1.1.33",
"resolved": "https://registry.npmjs.org/bootstrap5-autocomplete/-/bootstrap5-autocomplete-1.1.33.tgz",
"integrity": "sha512-VgHSx2hCNEBThFzb57HziDA2BNuc0wT5+V9XqIbXsV6oKYXcyRE2ytFIJcHjTCEIYqTsNCFiCQILIXc3YANGPQ==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",

View file

@ -14,6 +14,7 @@
"@fortawesome/fontawesome-free": "^6.5.2",
"@types/bootstrap": "^5.2.10",
"bootstrap": "^5.3.3",
"bootstrap5-autocomplete": "^1.1.33",
"datatables.net-bs5": "^2.0.8"
},
"devDependencies": {

View file

@ -1,24 +1,130 @@
import DataTable from "datatables.net-bs5";
import DataTable, {Api} from "datatables.net-bs5";
// @ts-expect-error
import Autocomplete from "bootstrap5-autocomplete";
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 DT_API_URL = '/api/dt/list/users';
let dtApi: Api;
function actionColumnRender(
id: number, type: string
) {
if (type !== 'display') {
return id;
}
const icon = document.createElement('i');
icon.classList.add('fa-solid', 'fa-xmark', 'text-danger', 'cursor-pointer', 'fa-xl');
icon.addEventListener('click', async () => {
const formData = new FormData();
formData.append('requestedUser', id.toString() ?? '');
formData.append('listid', getCurrentlySelectedList()?.toString() ?? '');
const response = await fetch(
REMOVE_API_URL,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
throw new Error(response.statusText);
}
dtApi.ajax.reload();
})
return icon;
}
export function initShare() {
const input = document.querySelector<HTMLInputElement>('#share-user-search');
if (!input) {
throw new Error("Missing search element");
}
let currentlySelected: number|null = null;
const autocomplete = new Autocomplete(input, {
server: SEARCH_API_URL,
liveServer: true,
fullWidth: true,
fixed: true,
onSelectItem(item: Object, instance: Autocomplete) {
// @ts-expect-error
currentlySelected = item.value;
}
})
document.querySelector<HTMLButtonElement>('.js--adds-user')?.addEventListener('click', async (e) => {
const formData = new FormData();
formData.append('requested', currentlySelected?.toString() ?? '');
formData.append('listid', getCurrentlySelectedList()?.toString() ?? '');
const response = await fetch(
ADD_API_URL,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
throw new Error(response.statusText);
}
dtApi.ajax.reload()
})
const table = document.querySelector<HTMLTableElement>('#shared-users-table');
if (!table) {
return;
}
new DataTable(table, {
dtApi = new DataTable(table, {
columns: [
{
name: '',
title: 'Icon',
data: 'icon',
className: 'avatar',
searchable: false,
orderable: false,
width: 'auto',
render(data, type) {
if (type !== 'display') {
return data;
}
return `<img src="${ data }" alt="Profile Picture" class="avatar-icon h-100 position-relative me-2 ratio-1 rounded-circle" />`;
}
},
{
name: 'Name',
data: 'name',
title: 'Name'
},
{
name: 'actions',
data: 'id',
render: actionColumnRender,
searchable: false,
orderable: false,
type: 'html'
}
],
ajax: {
url: DT_API_URL,
data(data) {
// @ts-expect-error
data.listid = getCurrentlySelectedList()
}
},
order: [ [1, "desc"] ]
});
}

View file

@ -3,6 +3,7 @@ declare(strict_types=1);
namespace GamesShop\Entities;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use GamesShop\Entities\Account\User;
@ -35,6 +36,7 @@ final class GamesList
{
$this->owner = $owner;
$this->name = $name;
$this->claimer = new ArrayCollection([$owner]);
}
@ -53,10 +55,13 @@ final class GamesList
return $this->name;
}
public function getClaimer(): array
public function getClaimer(): Collection
{
return $this->claimer;
}
public function addClaimer(User $claimer): void
{
$this->claimer[] = $claimer;
}
}

View file

@ -119,7 +119,7 @@ final class GameImporter
}
}
if ($values['key'] === null || $values['name'] === null || $values['store'] === null) {
if (empty($values['key']) || empty($values['name']) || empty($values['store'])) {
continue;
}

View file

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

View file

@ -0,0 +1,63 @@
<?php
namespace GamesShop\Routing\Api\DataTables;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
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;
final class SharedUsersEndpoint
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
) { }
/**
* @throws UnauthorizedException
* @throws ForbiddenException
*/
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->getQueryParams();
if (!array_key_exists('listid', $body)) {
throw new BadRequestException();
}
$list = $this->entityManager->getRepository(GamesList::class)->findOneBy([ 'owner' => $user, 'id' => $body['listid'] ]);
$claimer = $list->getClaimer();
return new JsonResponse(
[ 'data' => $claimer
->filter(fn ($claimerUser) => $claimerUser !== $user)
->map(
function (User $user) {
return [
'id' => $user->getId(),
'name' => $user->getName(),
'icon' => $user->getProfilePictureUrl()
];
}
)->toArray()
]
);
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response;
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 AddUserToList
{
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();
}
$requestedUser = $this->entityManager->getRepository(User::class)
->find($body['requested']);
if ($list->getClaimer()->contains($requestedUser)) {
return new Response();
}
$list->addClaimer($requestedUser);
$this->entityManager->flush();
return new Response();
}
}

View file

@ -0,0 +1,60 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response;
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 RemoveUserFromList
{
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();
}
$requestedUser = $this->entityManager->getRepository(User::class)
->find($body['requestedUser']);
if ($requestedUser === $user) {
return new Response();
}
$list->getClaimer()->removeElement($requestedUser);
$this->entityManager->flush();
return new Response();
}
}

View file

@ -0,0 +1,59 @@
<?php
namespace GamesShop\Routing\Api\Web;
use Doctrine\Common\Collections\Criteria;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User;
use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission;
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;
final class SearchForUsers
{
public function __construct(
private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager,
) { }
/**
* @throws ForbiddenException
* @throws UnauthorizedException
*/
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();
}
$searchQuery = $request->getQueryParams()['query'] ?? '';
$repo = $this->entityManager->getRepository(User::class);
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->contains('name', $searchQuery));
$criteria->setMaxResults(10);
$values = $repo->matching($criteria);
return new JsonResponse(
$values
->filter(fn ($value) => $value !== $user)
->map(function (User $user) {
return [
'value' => $user->getId(),
'label' => $user->getName()
];
})
->toArray()
);
}
}

View file

@ -14,5 +14,9 @@ final class WebAPIRoutes
$group->post('/keys/import/perform', ImportKeysRoute::class);
$group->post('/keys/list/create', CreateKeyListRoute::class);
$group->get('/share/search', SearchForUsers::class);
$group->post('/share/add', AddUserToList::class);
$group->post('/share/remove', RemoveUserFromList::class);
}
}

View file

@ -20,20 +20,11 @@ use Psr\Http\Message\ServerRequestInterface;
final class Router
{
public function __construct(
private ResourceRoute $resourceRoute
)
{
}
public function route(): ResponseInterface
{
$request = ServerRequestFactory::fromGlobals(
$_SERVER, $_GET, $_POST, $_COOKIE, $_FILES
);
if ($this->resourceRoute->isValid($request->getUri())) {
return $this->resourceRoute->getResponse($request->getUri());
}
$router = new \League\Route\Router;
$strategy = (new ApplicationStrategy)->setContainer(ContainerHandler::getInstance());

View file

@ -96,8 +96,7 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
<button class="btn btn-primary js--do-import">
Import
</button>
</div>
</button></div>
</div>
<div class="tab-pane fade" id="share-tab-pane" role="tabpanel">
<h2>
@ -107,16 +106,12 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
<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--search-shared-user">Search</button>
<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>
<tr>
<th width="2.4rem"></th>
<th>Name</th>
</tr>
</thead>
<tbody></tbody>
</table>

View file

@ -89,6 +89,9 @@ module.exports = {
},
resolve: {
extensions: ['.js', '.ts'],
fallback: {
'bootstrap5-autocomplete/types/autocomplete': 'bootstrap5-autocomplete'
}
},
entry: {
index: JS_FOLDER + "/pages/index",