implemented user lists

This commit is contained in:
Michel Fedde 2024-07-06 22:18:37 +02:00
parent 3218253076
commit c3e81ce6ea
16 changed files with 365 additions and 74 deletions

View file

@ -1,3 +1,6 @@
import {getCurrentlySelectedList} from "./userlists";
import {getTableAPI} from "./table";
async function checkImportFile() { async function checkImportFile() {
const fileInput = document.querySelector<HTMLInputElement>('#import'); const fileInput = document.querySelector<HTMLInputElement>('#import');
@ -108,6 +111,9 @@ async function doImport() {
formData.set(`columns[${columnIndex}]`, attribute); formData.set(`columns[${columnIndex}]`, attribute);
} }
const listId = getCurrentlySelectedList() ?? 0;
formData.set('listid', listId.toString());
const response = await fetch( const response = await fetch(
`/api/web/keys/import/perform`, `/api/web/keys/import/perform`,
{ {
@ -127,6 +133,8 @@ async function doImport() {
container.classList.add('d-none'); container.classList.add('d-none');
fileInput.value = ''; fileInput.value = '';
getTableAPI().ajax.reload();
} }
export function init() { export function init() {

View file

@ -5,6 +5,7 @@ import { Tab } from 'bootstrap';
import {init as initImport} from "./import"; import {init as initImport} from "./import";
import {init as initTable} from "./table"; import {init as initTable} from "./table";
import {init as initUserLists} from "./userlists";
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const triggerTabList = document.querySelectorAll('#key-tab button') const triggerTabList = document.querySelectorAll('#key-tab button')
@ -19,4 +20,5 @@ document.addEventListener('DOMContentLoaded', () => {
initImport(); initImport();
initTable(); initTable();
initUserLists();
}) })

View file

@ -1,12 +1,19 @@
import DataTable from "datatables.net-bs5"; import DataTable, {Api} from "datatables.net-bs5";
import 'datatables.net-bs5/css/dataTables.bootstrap5.css'; import 'datatables.net-bs5/css/dataTables.bootstrap5.css';
import {Key} from "../types/entities"; import {Key} from "../types/entities";
import {getIconForKeyState, getKeyStateExplanation} from "../types/keyState"; import {getIconForKeyState, getKeyStateExplanation} from "../types/keyState";
import {Dropdown} from "bootstrap"; import {Dropdown, Tooltip} from "bootstrap";
import {getCurrentlySelectedList} from "./userlists";
const TABLE_AJAX_URL = '/api/dt/keys/provider'; const TABLE_AJAX_URL = '/api/dt/keys/provider';
let tableAPI: Api;
export function getTableAPI(): Api {
return tableAPI;
}
function getKeyDisplay(parent: HTMLElement, keys: Key[]) { function getKeyDisplay(parent: HTMLElement, keys: Key[]) {
const header = document.createElement("h1"); const header = document.createElement("h1");
header.innerText = 'Keys'; header.innerText = 'Keys';
@ -16,19 +23,19 @@ function getKeyDisplay(parent: HTMLElement, keys: Key[]) {
table.classList.add('table', 'table-striped', 'w-100'); table.classList.add('table', 'table-striped', 'w-100');
const body = table.createTBody(); const body = table.createTBody();
for (const {fromWhere, keyState, key, store, store_link} of keys) { for (const {fromWhere, state, key, store, store_link} of keys) {
const row = body.insertRow(); const row = body.insertRow();
row.classList.add('cursor-pointer'); row.classList.add('cursor-pointer');
const stateCell = row.insertCell(); const stateCell = row.insertCell();
stateCell.classList.add('w-auto', 'align-content-center'); stateCell.classList.add('w-auto', 'align-content-center');
stateCell.innerHTML = `<a data-bs-toggle="tooltip" data-bs-title="${getKeyStateExplanation(keyState)}"><i class="fa-solid ${ getIconForKeyState(keyState) }"></i></a>` stateCell.innerHTML = `<a data-bs-toggle="tooltip" data-bs-title="${getKeyStateExplanation(state)}"><i class="fa-solid ${ getIconForKeyState(state) }"></i></a>`
const anchor = stateCell.querySelector('a'); const anchor = stateCell.querySelector('a');
if (!anchor) { if (!anchor) {
return; return;
} }
new Dropdown(anchor); new Tooltip(anchor);
row.insertCell().textContent = key; row.insertCell().textContent = key;
row.insertCell().textContent = store === 'external' ? store_link : store; row.insertCell().textContent = store === 'external' ? store_link : store;
@ -49,9 +56,13 @@ export function init() {
return; return;
} }
const table = new DataTable(keyTable, { const table = tableAPI = new DataTable(keyTable, {
ajax: { ajax: {
url: TABLE_AJAX_URL url: TABLE_AJAX_URL,
data: function (d) {
// @ts-ignore
d.listid = getCurrentlySelectedList();
}
}, },
processing: true, processing: true,
columns: [ columns: [
@ -72,7 +83,6 @@ export function init() {
} }
], ],
ordering: false, ordering: false,
serverSide: true,
order: [ [1, 'asc'] ], order: [ [1, 'asc'] ],
createdRow(row: Node, data: any) { createdRow(row: Node, data: any) {
const tableRow = <HTMLTableRowElement>row; const tableRow = <HTMLTableRowElement>row;

View file

@ -0,0 +1,71 @@
import {Modal} from "bootstrap";
import {getTableAPI} from "./table";
export function getCurrentlySelectedList(): number|null {
const listSelect = document.querySelector<HTMLSelectElement>('#list-select');
if (!listSelect) {
return null;
}
return parseInt(listSelect.value);
}
export function init() {
const modal = document.querySelector('#create-list-modal');
if (!modal) {
return;
}
const modalObj = new Modal(modal);
modal.addEventListener('show.bs.modal', (e) => {
const input = modal.querySelector<HTMLInputElement>('#createListName');
if (!input) {
return;
}
input.value = '';
})
modal.querySelector('.js--create-list')?.addEventListener('click', async (e) => {
const input = modal.querySelector<HTMLInputElement>('#createListName');
if (!input) {
return;
}
const newName = input.value;
const formData = new FormData();
formData.append('name', newName);
const response = await fetch(
`/api/web/keys/list/create`,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
throw new Error(response.statusText);
}
window.location.reload();
});
const listSelect = document.querySelector<HTMLSelectElement>('#list-select');
if (listSelect) {
listSelect.addEventListener('change', (e) => {
if (listSelect.value === '_create') {
modalObj.show()
return;
}
getTableAPI().ajax.reload();
})
}
const newButton = document.querySelector('.js--create-list-button');
if (newButton) {
newButton.addEventListener('click', () => modalObj.show())
}
}

View file

@ -18,7 +18,7 @@ export type Key = {
game?: Game, game?: Game,
key: string, key: string,
store: Store, store: Store,
keyState: KeyState, state: KeyState,
store_link: string|null, store_link: string|null,
fromWhere: string|null, fromWhere: string|null,
} }

View file

@ -7,7 +7,7 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'games')] #[ORM\Table(name: 'games')]
final class Game class Game
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: 'integer', options: ['unsigned' => true])] #[ORM\Column(type: 'integer', options: ['unsigned' => true])]

View file

@ -5,10 +5,12 @@ namespace GamesShop\Entities\Games;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use GamesShop\Entities\Account\User; use GamesShop\Entities\Account\User;
use GamesShop\Entities\GamesList;
use JsonSerializable;
#[ORM\Entity] #[ORM\Entity]
#[ORM\Table(name: 'keys')] #[ORM\Table(name: 'keys')]
final class Key final class Key implements JsonSerializable
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\Column(type: 'integer', options: ['unsigned' => true])] #[ORM\Column(type: 'integer', options: ['unsigned' => true])]
@ -17,7 +19,7 @@ final class Key
#[ORM\ManyToOne] #[ORM\ManyToOne]
private Game $game; private Game $game;
#[ORM\ManyToOne] #[ORM\ManyToOne]
private User $contributedUser; private GamesList $list;
#[ORM\Column] #[ORM\Column]
private string $key; private string $key;
#[ORM\Column(type: 'string', enumType: Store::class)] #[ORM\Column(type: 'string', enumType: Store::class)]
@ -29,10 +31,10 @@ final class Key
#[ORM\Column(type: 'integer', enumType: KeyState::class)] #[ORM\Column(type: 'integer', enumType: KeyState::class)]
private KeyState $state; private KeyState $state;
public function __construct(Game $game, User $contributedUser, string $key, Store $store, ?string $storeLink, ?string $fromWhere) public function __construct(Game $game, GamesList $list, string $key, Store $store, ?string $storeLink, ?string $fromWhere)
{ {
$this->game = $game; $this->game = $game;
$this->contributedUser = $contributedUser; $this->list = $list;
$this->key = $key; $this->key = $key;
$this->store = $store; $this->store = $store;
$this->storeLink = $storeLink; $this->storeLink = $storeLink;
@ -79,4 +81,16 @@ final class Key
{ {
return $this->state; return $this->state;
} }
public function jsonSerialize(): mixed
{
return [
'id' => $this->id,
'key' => $this->key,
'store' => $this->store->value,
'store_link' => $this->storeLink,
'from_where' => $this->fromWhere,
'state' => $this->state->value,
];
}
} }

View file

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace GamesShop\Entities;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use GamesShop\Entities\Account\User;
#[ORM\Entity]
#[ORM\Table(name: 'games_lists')]
final class GamesList
{
#[ORM\Id]
#[ORM\Column(type: 'integer', options: ['unsigned' => true])]
#[ORM\GeneratedValue]
private int|null $id;
#[ORM\ManyToOne]
private User $owner;
#[ORM\Column(nullable: true)]
private string|null $name;
#[ORM\JoinTable(name: 'games_list_claimer')]
#[ORM\JoinColumn(name: 'id', referencedColumnName: 'id')]
#[ORM\ManyToMany(targetEntity: User::class)]
private Collection $claimer;
/**
* @param User $owner
* @param string|null $name
*/
public function __construct(User $owner, ?string $name)
{
$this->owner = $owner;
$this->name = $name;
}
public function getId(): ?int
{
return $this->id;
}
public function getOwner(): User
{
return $this->owner;
}
public function getName(): ?string
{
return $this->name;
}
public function getClaimer(): array
{
return $this->claimer;
}
}

View file

@ -10,6 +10,7 @@ use GamesShop\Entities\Games\Game;
use GamesShop\Entities\Games\Key; use GamesShop\Entities\Games\Key;
use GamesShop\Entities\Games\KeyAttribute; use GamesShop\Entities\Games\KeyAttribute;
use GamesShop\Entities\Games\Store; use GamesShop\Entities\Games\Store;
use GamesShop\Entities\GamesList;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
final class GameImporter final class GameImporter
@ -75,7 +76,7 @@ final class GameImporter
/** /**
* @param string[] $columnDefinitions * @param string[] $columnDefinitions
*/ */
public function import(string $path, array $columnDefinitions, User $contributedUser): array { public function import(string $path, array $columnDefinitions, GamesList $list): array {
$spreadsheet = IOFactory::load($path); $spreadsheet = IOFactory::load($path);
$worksheet = $spreadsheet->getSheet(0); $worksheet = $spreadsheet->getSheet(0);
@ -129,7 +130,7 @@ final class GameImporter
$key = new Key( $key = new Key(
$game, $game,
$contributedUser, $list,
$values['key'], $values['key'],
$values['store'], $values['store'],
$values['store_link'], $values['store_link'],

View file

@ -9,9 +9,11 @@ use Doctrine\ORM\EntityManager;
use GamesShop\Entities\Account\User; use GamesShop\Entities\Account\User;
use GamesShop\Entities\Games\Game; use GamesShop\Entities\Games\Game;
use GamesShop\Entities\Games\Key; use GamesShop\Entities\Games\Key;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler; use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission; use GamesShop\Login\UserPermission;
use Laminas\Diactoros\Response\JsonResponse; use Laminas\Diactoros\Response\JsonResponse;
use League\Route\Http\Exception\BadRequestException;
use League\Route\Http\Exception\ForbiddenException; use League\Route\Http\Exception\ForbiddenException;
use League\Route\Http\Exception\UnauthorizedException; use League\Route\Http\Exception\UnauthorizedException;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
@ -37,62 +39,40 @@ final class ProviderKeysEndpoint
throw new ForbiddenException(); throw new ForbiddenException();
} }
$body = $request->getQueryParams();
if (!array_key_exists('listid', $body)) {
throw new BadRequestException();
}
$params = $request->getQueryParams(); $list = $this->entityManager->getRepository(GamesList::class)->findOneBy([ 'owner' => $user, 'id' => $body['listid'] ]);
$draw = $params['draw']; if (!$list instanceof GamesList) {
$start = $params['start']; throw new BadRequestException();
$length = $params['length']; }
$searchValue = $params['search']['value']; $keys = $this->entityManager->getRepository(Key::class)->findBy(['list' => $list]);
$gameToKeyArray = [];
foreach ($keys as $key) {
$game = $key->getGame();
$id = $game->getId();
$repo = $this->entityManager->getRepository(Game::class); if (!array_key_exists($id, $gameToKeyArray)) {
$total = $repo->count(); $gameToKeyArray[$id] = [ $game, [] ];
}
$criteria = Criteria::create(); $gameToKeyArray[$id][1][] = $key;
$criteria->where(Criteria::expr()->contains('name', $searchValue)); }
$criteria->setFirstResult((int)$start);
$criteria->setMaxResults((int)$length);
$criteria->orderBy([ 'name' => Order::Ascending ]);
$values = $repo->matching($criteria); $result = [];
$filteredCount = $values->count(); foreach ($gameToKeyArray as [$game, $keys]) {
$result[] = [
'gamePicture' => '',
'name' => $game->getName(),
'keysAmount' => count($keys),
'igdbState' => 'not implermented',
'keys' => $keys,
];
}
$entityManager = $this->entityManager; return new JsonResponse([ 'data' => $result ]);
return new JsonResponse([
'draw' => $draw,
'recordsTotal' => $total,
'recordsFiltered' => $filteredCount,
'data' =>
array_filter($values->map(function (Game $game) use ($entityManager, $user) {
$criteria = Criteria::create();
$criteria->where(Criteria::expr()->eq('game', $game));
$criteria->andWhere(Criteria::expr()->eq('contributedUser', $user));
$criteria->orderBy(['state' => Order::Ascending]);
$keys = $entityManager->getRepository(Key::class)->matching($criteria);
$keysAmount = $keys->count();
if ($keysAmount < 1) {
return null;
}
return [
'gamePicture' => '',
'name' => $game->getName(),
'keysAmount' => $keysAmount,
'igdbState' => 'yet to be implermented',
'keys' => $keys->map(function (Key $key) use ($entityManager) {
return [
'keyState' => $key->getState()->value,
'key' => $key->getKey(),
'store' => $key->getStore()->value,
'store_link' => $key->getStoreLink(),
'from' => $key->getFromWhere(),
];
})->toArray()
];
})->toArray())
]);
} }
} }

View file

@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
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 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 CreateKeyListRoute
{
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('name', $body)) {
throw new BadRequestException();
}
$name = $body['name'];
$list = new GamesList($user, $name);
$this->entityManager->persist($list);
$this->entityManager->flush();
return new Response();
}
}

View file

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace GamesShop\Routing\Api\Web; namespace GamesShop\Routing\Api\Web;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\GamesList;
use GamesShop\Importer\GameImporter; use GamesShop\Importer\GameImporter;
use GamesShop\Login\LoginHandler; use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission; use GamesShop\Login\UserPermission;
@ -19,6 +21,7 @@ final class ImportKeysRoute
public function __construct( public function __construct(
private readonly LoginHandler $loginHandler, private readonly LoginHandler $loginHandler,
private readonly GameImporter $importer, private readonly GameImporter $importer,
private readonly EntityManager $entityManager,
) { } ) { }
public function __invoke(ServerRequestInterface $request): ResponseInterface public function __invoke(ServerRequestInterface $request): ResponseInterface
@ -32,6 +35,16 @@ final class ImportKeysRoute
throw new ForbiddenException(); 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();
}
/** /**
* @var UploadedFile $file * @var UploadedFile $file
*/ */
@ -45,7 +58,7 @@ final class ImportKeysRoute
$columnDefs = $request->getParsedBody()['columns']; $columnDefs = $request->getParsedBody()['columns'];
[$total, $imported] = $this->importer->import($fileName, $columnDefs, $user); [$total, $imported] = $this->importer->import($fileName, $columnDefs, $list);
unlink($fileName); unlink($fileName);
return new JsonResponse([ 'success' => true, 'total' => $total, 'imported' => $imported ]); return new JsonResponse([ 'success' => true, 'total' => $total, 'imported' => $imported ]);

View file

@ -12,5 +12,7 @@ final class WebAPIRoutes
$group->post('/keys/import/prepare', ImportKeysPrepareRoute::class); $group->post('/keys/import/prepare', ImportKeysPrepareRoute::class);
$group->post('/keys/import/perform', ImportKeysRoute::class); $group->post('/keys/import/perform', ImportKeysRoute::class);
$group->post('/keys/list/create', CreateKeyListRoute::class);
} }
} }

View file

@ -13,7 +13,7 @@ final class ErrorRoute
public function renderErrorPage(int $errorCode): ResponseInterface { public function renderErrorPage(int $errorCode): ResponseInterface {
$pageContent = ContainerHandler::get(TemplateEngine::class)->renderErrorPage($errorCode); $pageContent = ContainerHandler::get(TemplateEngine::class)->renderErrorPage($errorCode);
$response = new Response; $response = new Response(status: $errorCode);
$response->getBody()->write($pageContent); $response->getBody()->write($pageContent);
return $response; return $response;
} }

View file

@ -3,6 +3,8 @@ declare(strict_types=1);
namespace GamesShop\Routing; namespace GamesShop\Routing;
use Doctrine\ORM\EntityManager;
use GamesShop\Entities\GamesList;
use GamesShop\Login\LoginHandler; use GamesShop\Login\LoginHandler;
use GamesShop\Login\UserPermission; use GamesShop\Login\UserPermission;
use GamesShop\Routing\Responses\TemplateResponse; use GamesShop\Routing\Responses\TemplateResponse;
@ -14,7 +16,8 @@ use Psr\Http\Message\ServerRequestInterface;
final class KeysRoute final class KeysRoute
{ {
public function __construct( public function __construct(
private readonly LoginHandler $loginHandler private readonly LoginHandler $loginHandler,
private readonly EntityManager $entityManager
) )
{ {
} }
@ -30,8 +33,9 @@ final class KeysRoute
throw new ForbiddenException(); throw new ForbiddenException();
} }
$entityManager = $this->entityManager->getRepository(GamesList::class);
return new TemplateResponse('key-manager'); $lists = $entityManager->findBy([ 'owner' => $user ]);
return new TemplateResponse('key-manager', [ 'usersLists' => $lists ]);
} }
public static function applyRoutes(\League\Route\Router $router): void public static function applyRoutes(\League\Route\Router $router): void

View file

@ -2,15 +2,44 @@
declare(strict_types=1); declare(strict_types=1);
use GamesShop\Entities\Games\KeyAttribute; use GamesShop\Entities\Games\KeyAttribute;
use GamesShop\Entities\GamesList;
use League\Plates\Template\Template; use League\Plates\Template\Template;
assert($this instanceof Template); assert($this instanceof Template);
/** @var GamesList[] $usersLists */
$this->layout('layout/main', [ 'resourceEntry' => 'keys' ]); $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
?> ?>
<meta name="key-attributes" content="<?= htmlspecialchars(json_encode(KeyAttribute::casesAsAssociative())) ?>" /> <meta name="key-attributes" content="<?= htmlspecialchars(json_encode(KeyAttribute::casesAsAssociative())) ?>" />
<h1>My Keys</h1> <div class="row">
<div class="col-sm-6">
<h1>My Keys</h1>
</div>
<div class="col-sm-6 align-self-center">
<?php if (!empty($usersLists)): ?>
<select name="lists" id="list-select" class="form-select w-100">
<?php foreach ($usersLists as $list): ?>
<option value="<?= $list->getId() ?>"><?= $list->getName() ?></option>
<?php endforeach; ?>
<option value="_create">+ Create New</option>
</select>
<?php endif; ?>
</div>
</div>
<?php if (empty($usersLists)): ?>
<div class="text-center">
<p class="fs-4 mb-4">You don't have a key list. Create one here.</p>
<button class="btn btn-primary btn-lg js--create-list-button">
Create
</button>
</div>
<?php else: ?>
<ul id="key-tab" class="nav nav-tabs"> <ul id="key-tab" class="nav nav-tabs">
<li class="nav-item"> <li class="nav-item">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#keys-tab-pane" role="tab"> <button class="nav-link active" data-bs-toggle="tab" data-bs-target="#keys-tab-pane" role="tab">
@ -22,6 +51,11 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
Import Import
</button> </button>
</li> </li>
<li class="nav-item">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#share-tab-pane" role="tab">
Share
</button>
</li>
</ul> </ul>
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade show active" id="keys-tab-pane" role="tabpanel"> <div class="tab-pane fade show active" id="keys-tab-pane" role="tabpanel">
@ -65,4 +99,45 @@ $this->layout('layout/main', [ 'resourceEntry' => 'keys' ]);
</button> </button>
</div> </div>
</div> </div>
</div> <div class="tab-pane fade" id="share-tab-pane" role="tabpanel">
<h2>
Share your list
</h2>
<label for="share-user-search">Search for a user...</label>
<div class="input-group">
<input type="text" class="form-control" id="share-user-search" placeholder="">
<button class="btn btn-primary js--search-shared-user">Search</button>
</div>
</div>
</div>
<?php endif; ?>
<?php $this->start('modal') ?>
<div class="modal" id="create-list-modal">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title h3">
Create list
</h1>
</div>
<div class="modal-body">
<div class="form-floating">
<input class="form-control" type="text" id="createListName" placeholder="">
<label for="createListName">Name</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary js--create-list">Create</button>
</div>
</div>
</div>
</div>
<?php $this->end() ?>