From 4cacc94240944ff316104bfd1b5e8e00fad14517 Mon Sep 17 00:00:00 2001
From: Daniel Weipert <code@drogueronin.de>
Date: Fri, 14 Jan 2022 18:21:36 +0100
Subject: Add Artworks, Votes and better routing

---
 .gitignore                |   2 +
 public/index.php          |  20 ---------
 src/Controller/Card.php   |  42 +++++++++++++++++++
 src/Controller/Home.php   |  11 ++++-
 src/Model/Artwork.php     | 101 ++++++++++++++++++++++++++++++++++++++++++++++
 src/Model/Card.php        |  49 ++++++++++++++++++++++
 src/Model/Vote.php        |  30 ++++++++++++++
 src/Model/VoteArtwork.php |  26 ++++++++++++
 src/Model/VoteCard.php    |  26 ++++++++++++
 src/Router.php            |  81 +++++++++++++++++++++----------------
 templates/home.twig       |  12 ++++++
 11 files changed, 344 insertions(+), 56 deletions(-)
 create mode 100644 src/Model/Artwork.php
 create mode 100644 src/Model/Vote.php
 create mode 100644 src/Model/VoteArtwork.php
 create mode 100644 src/Model/VoteCard.php

diff --git a/.gitignore b/.gitignore
index 8504ffb..82aabcc 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
 /vendor/
 db.sqlite
 
+/public/artworks
+
diff --git a/public/index.php b/public/index.php
index 57a5d91..022af48 100644
--- a/public/index.php
+++ b/public/index.php
@@ -6,23 +6,3 @@ require_once dirname(__DIR__) . '/vendor/autoload.php';
 \Elements\DB::init();
 new \Elements\Router();
 
-$fields = [
-  'name' => 'meta[name]',
-  'converted mana cost' => 'meta[cmc]',
-  'is uno reverse' => 'meta[is_uno_reverse]',
-];
-?>
-
-<form action="<?= '/card/add' ?>" method="post" enctype="multipart/form-data">
-  <?php foreach ($fields as $key => $field): ?>
-  <label>
-    <?= $key ?> <input type="text" name="<?= $field ?>">
-  </label>
-  <?php endforeach; ?>
-  <input type="submit" value="Hinzufügen">
-</form>
-
-<?php
-
-
-
diff --git a/src/Controller/Card.php b/src/Controller/Card.php
index 39560bb..d4ee393 100644
--- a/src/Controller/Card.php
+++ b/src/Controller/Card.php
@@ -3,8 +3,12 @@
 namespace Elements\Controller;
 
 use Elements\DB;
+use Elements\Model\Artwork;
 use Elements\Model\Card as CardModel;
 use Elements\Model\CardMeta;
+use Elements\Model\VoteArtwork;
+use Elements\Model\VoteCard;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
@@ -21,11 +25,49 @@ class Card
       DB::save($meta);
     }
 
+    /**@var UploadedFile[] $files*/
+    if ($files = $request->files->get('images')) {
+      foreach ($files as $file) {
+	$image = Artwork::fromUploadedFile($file);
+
+	/*TEST*/
+	for ($i = 0; $i <= rand(0, 40); $i++) {
+	  $vote = new VoteArtwork();
+	  $vote->value = rand(-1, 1) >= 0 ? 1 : -1;
+	  $image->addVote($vote);
+	  DB::save($vote);
+	}
+	/*TEST*/
+
+	$card->addArtwork($image);
+	DB::save($image);
+      }
+    }
+
+    /*TEST*/
+    for ($i = 0; $i <= rand(0, 60); $i++) {
+      $vote = new VoteCard();
+      $vote->value = rand(-1, 1) > 0 ? 1 : -1;
+      $card->addVote($vote);
+      DB::save($vote);
+    }
+    /*TEST*/
+
     DB::save($card);
 
     $response = new RedirectResponse('/');
 
     return $response;
   }
+
+  public static function get(Request $request)
+  {
+    $route = $request->attributes->get('route');
+    $cardId = $route['id'];
+
+    $card = DB::$entityManager->getRepository(CardModel::class)->find($cardId);
+
+    return $card->getMeta('name');
+  }
 }
 
diff --git a/src/Controller/Home.php b/src/Controller/Home.php
index 511b89b..1c1500c 100644
--- a/src/Controller/Home.php
+++ b/src/Controller/Home.php
@@ -14,9 +14,18 @@ class Home
     echo "<pre>";
     $c && var_dump(
       array_map(fn ($item) => [$item->key, $item->value], $c->meta->toArray()),
+      array_map(fn ($item) => "<img style='max-width: 100%;max-height:300px;' src='$item->path'> Votes: " . $item->getVotesTotal(), $c->artworks->toArray()),
+      array_map(fn ($item) => $item->value, $c->votes->toArray()),
+      'Votes: ' . $c->getVotesTotal(),
     );
 
-    return Template::render('home.twig');
+    return Template::render('home.twig', [
+      'fields' => [
+	'name' => 'meta[name]',
+	'converted mana cost' => 'meta[cmc]',
+	'is uno reverse' => 'meta[is_uno_reverse]',
+      ],
+    ]);
   }
 }
 
diff --git a/src/Model/Artwork.php b/src/Model/Artwork.php
new file mode 100644
index 0000000..25f6ee5
--- /dev/null
+++ b/src/Model/Artwork.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Elements\Model;
+
+use Doctrine\Common\Collections\ArrayCollection;
+use Doctrine\DBAL\Schema\Column;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+use Doctrine\ORM\Mapping\ManyToOne;
+use Doctrine\ORM\PersistentCollection;
+use Elements\DB;
+use Symfony\Component\HttpFoundation\File\UploadedFile;
+
+#[Entity]
+#[Table(name: 'artworks')]
+/**
+ * @Entity
+ * @Table(name="artworks")
+ */
+class Artwork
+{
+  #[Id]
+  #[Column(type: 'integer')]
+  #[GeneratedValue]
+  /**
+   * @Id
+   * @Column(type="integer")
+   * @GeneratedValue
+   */
+  public int $id;
+
+  #[Column(type: 'string')]
+  /**
+   * @Column(type="string")
+   */
+  public string $path;
+
+  #[ManyToOne(targetEntity: Card::class, inversedBy: 'artworks')]
+  /**
+   * @ManyToOne(targetEntity="Card", inversedBy="artworks", cascade={"persist"})
+   */
+  public Card $card;
+
+  #[OneToMany(targetEntity: VoteArtwork::class, mappedBy: 'artwork')]
+  /**
+   * @OneToMany(targetEntity="VoteArtwork", mappedBy="artwork")
+   */
+  public Collection|ArrayCollection|PersistentCollection $votes;
+
+  /**
+   * Artwork constructor.
+   */
+  public function __construct(string $path)
+  {
+    $this->path = $path;
+    $this->votes = new ArrayCollection();
+  }
+
+  /**
+   * @param UploadedFile $file
+   *
+   * @return self
+   */
+  public static function fromUploadedFile(UploadedFile $file): self
+  {
+    $artworksDir = dirname(dirname(__DIR__)) . '/public/artworks/';
+    $path = str_replace($_SERVER['DOCUMENT_ROOT'], '', $artworksDir . $file->getClientOriginalName());
+    $file->move($artworksDir, $file->getClientOriginalName());
+
+    return new self($path);
+  }
+
+  /**
+   * @param VoteArtwork $vote
+   */
+  public function addVote(VoteArtwork $vote)
+  {
+    $vote->artwork = $this;
+    $this->votes[] = $vote;
+  }
+
+  /**
+   * @return int
+   */
+  public function getVotesTotal(): int
+  {
+    $result = DB::$entityManager
+      ->createQuery(
+	'SELECT sum(v.value) as total
+	FROM Elements\Model\VoteArtwork v
+	WHERE v.artwork = :artwork'
+      )
+      ->setParameter('artwork', $this)
+      ->getOneOrNullResult();
+
+      return $result['total'] ?? 0;
+  }
+}
+
diff --git a/src/Model/Card.php b/src/Model/Card.php
index ab421ab..9707e16 100644
--- a/src/Model/Card.php
+++ b/src/Model/Card.php
@@ -37,12 +37,26 @@ class Card
    */
   public Collection|ArrayCollection|PersistentCollection $meta;
 
+  #[OneToMany(targetEntity: Artwork::class, mappedBy: 'card')]
+  /**
+   * @OneToMany(targetEntity="Artwork", mappedBy="card")
+   */
+  public Collection|ArrayCollection|PersistentCollection $artworks;
+
+  #[OneToMany(targetEntity: VoteCard::class, mappedBy: 'card')]
+  /**
+   * @OneToMany(targetEntity="VoteCard", mappedBy="card")
+   */
+  public Collection|ArrayCollection|PersistentCollection $votes;
+
   /**
    * Card constructor.
    */
   public function __construct()
   {
     $this->meta = new ArrayCollection();
+    $this->artworks = new ArrayCollection();
+    $this->votes = new ArrayCollection();
   }
 
   /**
@@ -54,6 +68,24 @@ class Card
     $this->meta[] = $meta;
   }
 
+  /**
+   * @param Artwork $artwork
+   */
+  public function addArtwork(Artwork $artwork)
+  {
+    $artwork->card = $this;
+    $this->artworks[] = $artwork;
+  }
+
+  /**
+   * @param VoteCard $vote
+   */
+  public function addVote(VoteCard $vote)
+  {
+    $vote->card = $this;
+    $this->votes[] = $vote;
+  }
+
   /**
    * @param string $key
    *
@@ -83,5 +115,22 @@ class Card
 
       return $result['value'] ?? null;
   }
+
+  /**
+   * @return int
+   */
+  public function getVotesTotal(): int
+  {
+    $result = DB::$entityManager
+      ->createQuery(
+	'SELECT sum(v.value) as total
+	FROM Elements\Model\VoteCard v
+	WHERE v.card = :card'
+      )
+      ->setParameter('card', $this)
+      ->getOneOrNullResult();
+
+      return $result['total'] ?? 0;
+  }
 }
 
diff --git a/src/Model/Vote.php b/src/Model/Vote.php
new file mode 100644
index 0000000..c5415aa
--- /dev/null
+++ b/src/Model/Vote.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Elements\Model;
+
+use Doctrine\DBAL\Schema\Column;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+use Doctrine\ORM\Mapping\ManyToOne;
+
+class Vote
+{
+  #[Id]
+  #[Column(type: 'integer')]
+  #[GeneratedValue]
+  /**
+   * @Id
+   * @Column(type="integer")
+   * @GeneratedValue
+   */
+  public int $id;
+
+  #[Column(type: 'integer')]
+  /**
+   * @Column(type="integer")
+   */
+  public int $value;
+}
+
diff --git a/src/Model/VoteArtwork.php b/src/Model/VoteArtwork.php
new file mode 100644
index 0000000..ee6ac8b
--- /dev/null
+++ b/src/Model/VoteArtwork.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Elements\Model;
+
+use Doctrine\DBAL\Schema\Column;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+use Doctrine\ORM\Mapping\ManyToOne;
+
+#[Entity]
+#[Table(name: 'votes_artwork')]
+/**
+ * @Entity
+ * @Table(name="votes_artwork")
+ */
+class VoteArtwork extends Vote
+{
+  #[ManyToOne(targetEntity: Artwork::class, inversedBy: 'votes')]
+  /**
+   * @ManyToOne(targetEntity="Artwork", inversedBy="votes", cascade={"persist"})
+   */
+  public Artwork $artwork;
+}
+
diff --git a/src/Model/VoteCard.php b/src/Model/VoteCard.php
new file mode 100644
index 0000000..e9b3e60
--- /dev/null
+++ b/src/Model/VoteCard.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Elements\Model;
+
+use Doctrine\DBAL\Schema\Column;
+use Doctrine\DBAL\Schema\Table;
+use Doctrine\ORM\Mapping\Entity;
+use Doctrine\ORM\Mapping\GeneratedValue;
+use Doctrine\ORM\Mapping\Id;
+use Doctrine\ORM\Mapping\ManyToOne;
+
+#[Entity]
+#[Table(name: 'votes_card')]
+/**
+ * @Entity
+ * @Table(name="votes_card")
+ */
+class VoteCard extends Vote
+{
+  #[ManyToOne(targetEntity: Card::class, inversedBy: 'votes')]
+  /**
+   * @ManyToOne(targetEntity="Card", inversedBy="votes", cascade={"persist"})
+   */
+  public Card $card;
+}
+
diff --git a/src/Router.php b/src/Router.php
index 377c00b..5635d5c 100644
--- a/src/Router.php
+++ b/src/Router.php
@@ -7,64 +7,75 @@ use Elements\Controller\Home;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 use Symfony\Component\HttpFoundation\Response;
+use Symfony\Component\Routing\Matcher\UrlMatcher;
+use Symfony\Component\Routing\RequestContext;
+use Symfony\Component\Routing\Route;
+use Symfony\Component\Routing\RouteCollection;
 
 class Router
 {
-  private $routes = [
-    'GET' => [
-      '/' => [Home::class, 'index'],
-    ],
-
-    'POST' => [
-      '/card/add' => [Card::class, 'add'],
-    ],
-  ];
+  private ?RouteCollection $routes;
+  private array $routeFunctions = [];
 
   /**
    * Router constructor.
    */
   public function __construct()
   {
+    /**@var Request $request*/
     $request = Request::createFromGlobals();
     $response = new Response();
 
-    $method = $request->getMethod();
-    $path = $request->getPathInfo();
+    $this->routes = new RouteCollection();
+    $this->addRoute('GET', '/', [Home::class,  'index']);
+    $this->addRoute('GET', '/card/{id}', [Card::class,  'get']);
+    $this->addRoute('POST', '/card/add', [Card::class,  'add']);
+
+    $context = new RequestContext();
+    $context->fromRequest($request);
+    $matcher = new UrlMatcher($this->routes, $context);
 
-    // if the route is defined
-    if (isset($this->routes[$method][$path])) {
-      try {
-	// run controller function
-	$content = call_user_func($this->routes[$method][$path], $request);
+    try {
+      $match = $matcher->match($request->getPathInfo());
+      $request->attributes->set('route', $match);
 
-	// set response to new response
-	if ($content instanceof Response) {
-	  $response = $content;
-	}
+      // run controller function
+      $content = call_user_func($this->routeFunctions[$match['_route']], $request);
 
-	// set content directly otherwise
-	else {
-	  $response->setContent($content);
-	}
+      // set response to new response
+      if ($content instanceof Response) {
+	$response = $content;
       }
 
-      // catch any errors
-      catch (AppException $exception) {
-	$response->setStatusCode($exception->getCode());
-	$response->setContent($exception->getMessage());
-      } catch (\Exception $exception) {
-	$response->setStatusCode(Response::HTTP_BAD_REQUEST);
-	$response->setContent($exception->getMessage());
+      // set content directly otherwise
+      else {
+	$response->setContent($content);
       }
     }
 
-    // route is not defined
-    else {
-      $response->setStatusCode(Response::HTTP_NOT_FOUND);
-      $response->setContent('Not Found');
+    // catch any errors
+    catch (AppException $exception) {
+      $response->setStatusCode($exception->getCode());
+      $response->setContent($exception->getMessage());
+    } catch (\Exception $exception) {
+      $response->setStatusCode(Response::HTTP_BAD_REQUEST);
+      $response->setContent($exception->getMessage());
     }
 
     $response->send();
   }
+
+  /**
+   * @param string|array $methods
+   * @param string $path
+   * @param callable $function
+   */
+  public function addRoute(string|array $methods, string $path, array $function)
+  {
+    $name = "$function[0]::$function[1]";
+
+    $this->routes->add($name, new Route($path, methods: (array)$methods));
+    $this->routeFunctions[$name] = $function;
+  }
 }
 
diff --git a/templates/home.twig b/templates/home.twig
index bc5b812..d4e86ed 100644
--- a/templates/home.twig
+++ b/templates/home.twig
@@ -1,2 +1,14 @@
 <h3>Home</h3>
 
+<form action="/card/add" method="post" enctype="multipart/form-data">
+  {% for key, field in fields %}
+    <label>
+      {{ key }} <input type="text" name="{{ field }}">
+    </label>
+  {% endfor %}
+  <label>
+    Image <input type="file" name="images[]" multiple>
+  </label>
+  <input type="submit" value="Hinzufügen">
+</form>
+
-- 
cgit v1.2.3