From 001210c3bd3b60736cabcd02b983d6259b7543f3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andrej=20Rama=C5=A1euski?= <andrej@x2.cz>
Date: Fri, 6 Jan 2023 01:33:04 +0100
Subject: [PATCH] Kompletni refaktoring

---
 TODO                               |   2 +-
 VERSION                            |   2 +-
 lib/PZ.pm                          |   6 +-
 lib/PZ/Controller/Log.pm           |  17 +-
 lib/PZ/Controller/Shortcut.pm      | 144 ++++--------
 lib/PZ/Controller/Stat.pm          |  10 +-
 lib/PZ/Helpers/Core.pm             |  25 ++-
 lib/PZ/Schema/Result/Log.pm        |   9 +
 lib/PZ/Schema/Result/Shortcut.pm   |   1 +
 openapi.yaml                       |   7 +-
 sql/migrations.sql                 |   3 +
 templates/index.html.ep            |  10 +-
 templates/layouts/default.html.ep  |  29 +--
 templates/log.csv.html.ep          |   2 +-
 templates/shortcut.html.ep         | 128 +++--------
 templates/shortcut/created.html.ep |  14 --
 templates/shortcut/form.html.ep    |  30 ---
 templates/shortcut/invalid.html.ep |   4 -
 templates/shortcuts-vue.html.ep    |  53 -----
 templates/shortcuts.html.ep        | 348 +++++++++++++++++++++--------
 20 files changed, 400 insertions(+), 444 deletions(-)
 delete mode 100644 templates/shortcut/created.html.ep
 delete mode 100644 templates/shortcut/form.html.ep
 delete mode 100644 templates/shortcut/invalid.html.ep
 delete mode 100644 templates/shortcuts-vue.html.ep

diff --git a/TODO b/TODO
index 30ecaf9..a4bc869 100644
--- a/TODO
+++ b/TODO
@@ -1,3 +1,3 @@
-- statistika
 - loop detect
 - 302 nebo informacni stranka
+- https://apexcharts.com/
diff --git a/VERSION b/VERSION
index f0bb29e..227cea2 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.3.0
+2.0.0
diff --git a/lib/PZ.pm b/lib/PZ.pm
index cb5f488..3fa671a 100644
--- a/lib/PZ.pm
+++ b/lib/PZ.pm
@@ -102,10 +102,8 @@ sub startup {
     $r->get('/logout')->to('OIDC#do_logout');
 
     $r->get('/')->to(cb => sub { shift->render('index'); });
-    $r->post('/')->to('Shortcut#create');
-    $r->get('/shortcuts')->to(cb => sub { shift->render('shortcuts-vue'); });
-    $r->get('/shortcuts/:id')->to('Shortcut#get1');
-    $r->get('/shortcuts/:id/log.csv')->to('Log#csv');
+    $r->get('/shortcut/:id')->to(cb => sub { shift->render('shortcut'); });
+    $r->get('/shortcut/:id/log.csv')->to('Log#csv');
 
     $r->get('/:shortcut')->to('Shortcut#redirect');
     $r->get('/:shortcut/qr.png')->to('Shortcut#qr');
diff --git a/lib/PZ/Controller/Log.pm b/lib/PZ/Controller/Log.pm
index 3a66a82..0799f66 100644
--- a/lib/PZ/Controller/Log.pm
+++ b/lib/PZ/Controller/Log.pm
@@ -4,11 +4,7 @@ use Mojo::Base 'Mojolicious::Controller', -signatures;
 sub main ($c) {
     $c->openapi->valid_input or return;
 
-    my $shortcut = $c->stash->{user}->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    return $c->error(404, 'NOT_FOUND') if ! $shortcut;
+    my $shortcut = $c->shortcut() || return;
 
     my ($log, @result);
 
@@ -22,9 +18,10 @@ sub main ($c) {
 
     ITEM:
     while ( my $item = $log->next ) {
+
         push @result, {
             time     => $item->time,
-            ip       => $item->ip,
+            ip       => $item->anonymized_ip,
             ua       => $item->ua,
             referrer => $item->referrer,
         };
@@ -35,11 +32,7 @@ sub main ($c) {
 
 sub csv ($c) {
 
-    my $shortcut = $c->current_user->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    return $c->error(404, 'NOT_FOUND') if ! $shortcut;
+    my $shortcut = $c->shortcut() || return;
 
     my ($log, @result);
 
@@ -58,5 +51,5 @@ sub csv ($c) {
         log      => $log,
     );
 }
-1;
 
+1;
diff --git a/lib/PZ/Controller/Shortcut.pm b/lib/PZ/Controller/Shortcut.pm
index e7e4941..82133dc 100644
--- a/lib/PZ/Controller/Shortcut.pm
+++ b/lib/PZ/Controller/Shortcut.pm
@@ -4,6 +4,8 @@ use Mojo::Base 'Mojolicious::Controller', -signatures;
 use Data::Validate::URI qw(is_uri);
 use Image::PNG::QRCode 'qrpng';
 
+use constant SHORTCUT => qr/^[a-z\d\-]{1,8}$/;
+
 sub redirect ($c) {
     my $shortcut = $c->schema->resultset('Shortcut')->search({
         shortcut  => $c->stash->{shortcut},
@@ -16,6 +18,10 @@ sub redirect ($c) {
         return;
     }
 
+    $shortcut->update({
+        counter => $shortcut->counter + 1
+    });
+
     $shortcut->add_to_log_items({
         ip       => ($c->forwarded_for || $c->tx->remote_address),
         ua       => $c->req->headers->user_agent,
@@ -27,65 +33,62 @@ sub redirect ($c) {
 }
 
 sub create ($c) {
-    my $url = $c->param('url');
+    $c->openapi->valid_input or return;
+    my $args = $c->req->json;
 
-    if( ! is_uri($url) ) {
-        $c->render('shortcut/invalid', error => 'Chybná URL adresa!');
-        return;
-    }
+    my $url = $args->{url};
+    return $c->error(400, 'Chybná URL adresa!') if ! is_uri($url);
 
-    my $custom = lc($c->param('shortcut'));
-
-    if ( ! $custom =~ /^[a..z0..9]{1,8}$/i ) {
-        $c->render('shortcut/invalid', error => 'Neplatná zkratka');
-        return;
-    }
+    my $custom = lc($args->{shortcut});
 
     if ( $custom ) {
+        return $c->error(400, 'Neplatná zkratka') if $custom !~ SHORTCUT;
+
         my $exists = $c->schema->resultset('Shortcut')->search({
-            is_active => 1,
             deleted   => undef,
             shortcut  => $custom,
         })->count;
 
-        if( $exists ) {
-            $c->render('shortcut/invalid', error => "Zkratka $custom už je použitá");
-            return;
-        }
+        return $c->error(400, "Zkratka $custom už je použitá") if $exists;
     }
 
-    my %data = (
-        deleted => undef,
-        url     => $url,
-    );
-
-    my $shortcut = $c->current_user->shortcuts(\%data)->first;
-
-    if ( ! $shortcut ) {
-        $data{shortcut} = $custom || $c->schema->resultset('Shortcut')->generate();
-        $shortcut = $c->current_user->add_to_shortcuts(\%data);
-    }
+    my $shortcut = $c->user->add_to_shortcuts({
+        url      => $c->sanitize_url($url),
+        shortcut => ($custom || $c->schema->resultset('Shortcut')->generate())
+    });
 
-    $c->render('shortcut/created',
-        url => 'https://' . $c->config->{domain} . '/' . $shortcut->shortcut,
-        shortcut => $shortcut
-     );
+    $c->render(
+        status  => 201,
+        openapi => { id => $shortcut->id },
+    );
 }
 
+sub get ($c) {
+    my $shortcut = $c->shortcut() || return;
+    $c->render(json =>  { $shortcut->get_columns }   );
+}
 
-sub qr ($c) {
-    my $url = 'https://' . $c->config->{domain} . '/' . $c->stash->{shortcut};
-    my $png = qrpng (text => $url, level => 4);
-    $c->res->headers->content_type('image/png');
-    $c->render( data => $png );
+sub update ($c) {
+    my $shortcut = $c->shortcut() || return;
+    return $c->error(400, 'Chybná URL adresa!') if ! is_uri($c->req->json->{url});
+    $shortcut->update({
+        code => $c->req->json->{code},
+        url  => $c->req->json->{url},
+    });
+    $c->render(status => 204, text => '' );
 }
 
+sub delete ($c) {
+    my $shortcut = $c->shortcut() || return;
+    $shortcut->update({ deleted => \'now()' });
+    $c->render(status => 204, text => '' );
+}
 
 sub list ($c) {
     my @shortcuts = ();
 
     SHORTCUT:
-    foreach my $shortcut ( $c->stash->{user}->shortcuts(
+    foreach my $shortcut ( $c->user->shortcuts(
         { deleted  => undef },
         { order_by => 'shortcut' },
     ) ) {
@@ -97,69 +100,12 @@ sub list ($c) {
     $c->render(json =>  \@shortcuts,  );
 }
 
-sub get ($c) {
-
-    my $shortcut = $c->stash->{user}->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    if ( ! $shortcut ) {
-        $c->render( status => 404, text => 'not found' );
-        return;
-    }
 
-    $c->render(json =>  { $shortcut->get_columns }   );
-}
-
-sub get1 ($c) {
-
-    my $shortcut = $c->current_user->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    if ( ! $shortcut ) {
-        $c->render( status => 404, text => 'not found' );
-        return;
-    }
-
-    $c->stash->{shortcut} = $shortcut;
-    $c->stash->{template} = 'shortcut';
-}
-sub update ($c) {
-
-    my $shortcut = $c->stash->{user}->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    if ( ! $shortcut ) {
-        $c->render( status => 404, text => 'not found' );
-        return;
-    }
-
-    $shortcut->update({
-        code => $c->req->json->{code},
-        url  => $c->req->json->{url},
-    });
-
-    $c->render(status => 204, text => '' );
-}
-
-sub delete ($c) {
-
-    my $shortcut = $c->stash->{user}->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    if ( ! $shortcut ) {
-        $c->render( status => 404, text => 'not found' );
-        return;
-    }
-
-    $shortcut->update({
-        deleted => \'now()'
-    });
-
-    $c->render(status => 204, text => '' );
+sub qr ($c) {
+    my $url = 'https://' . $c->config->{domain} . '/' . $c->stash->{shortcut};
+    my $png = qrpng (text => $url, level => 4, scale => 5);
+    $c->res->headers->content_type('image/png');
+    $c->render( data => $png );
 }
 
 1;
diff --git a/lib/PZ/Controller/Stat.pm b/lib/PZ/Controller/Stat.pm
index 504a980..0e03ad2 100644
--- a/lib/PZ/Controller/Stat.pm
+++ b/lib/PZ/Controller/Stat.pm
@@ -4,11 +4,7 @@ use Mojo::Base 'Mojolicious::Controller', -signatures;
 use DateTime;
 
 sub main ($c) {
-    my $shortcut = $c->stash->{user}->shortcuts({
-        id => $c->stash->{id}
-    })->first;
-
-    return $c->error(404, 'NOT_FOUND') if ! $shortcut;
+    my $shortcut = $c->shortcut() || return;
 
     my %tz = (time_zone => 'Europe/Prague');
     my $begin = DateTime->from_epoch(%tz, epoch => $c->param('begin') / 1000);
@@ -16,9 +12,9 @@ sub main ($c) {
 
     my ($stat, @result);
 
-    $stat = $shortcut->stat_daily_items(period => {
+    $stat = $shortcut->stat_daily_items({period => {
           between => [$begin, $end]
-    });
+    }});
 
     ITEM:
     while ( my $item = $stat->next ) {
diff --git a/lib/PZ/Helpers/Core.pm b/lib/PZ/Helpers/Core.pm
index f566d9a..3e69092 100644
--- a/lib/PZ/Helpers/Core.pm
+++ b/lib/PZ/Helpers/Core.pm
@@ -3,6 +3,7 @@ package PZ::Helpers::Core;
 use base 'Mojolicious::Plugin';
 
 use YAML;
+use Mojo::URL;
 
 sub register  {
     my ($class, $self ) = @_;
@@ -12,8 +13,6 @@ sub register  {
         my $status = shift;
         my $errors = [];
 
-        $c->cirpack_ws->rollback();
-
         if ( scalar @_ == 2 ) {
             $errors = [{ code => shift, message => shift }];
         }
@@ -80,6 +79,28 @@ sub register  {
         return $data;
     });
 
+    $self->helper( shortcut => sub {
+        my $c  = shift;
+        my $id = shift // $c->stash->{id};
+        my $shortcut = $c->schema->resultset('Shortcut')->find({ id => $id });
+        return $c->error(404, 'NOT_FOUND') if ! $shortcut;
+        return $c->error(403, 'ACCESS_DENIED') if $shortcut->user_id != $c->stash->{user}->id;
+        return $shortcut;
+    });
+
+    $self->helper( sanitize_url => sub {
+        my $c   = shift;
+
+        my $url = Mojo::URL->new(shift);
+        $url->query({ fbclid => undef });
+
+        return $url->to_string;
+    });
+
+    $self->helper( user => sub {
+        my $c      = shift;
+        return $c->stash->{user};
+    });
 }
 
 1;
diff --git a/lib/PZ/Schema/Result/Log.pm b/lib/PZ/Schema/Result/Log.pm
index 57686e5..27a169d 100644
--- a/lib/PZ/Schema/Result/Log.pm
+++ b/lib/PZ/Schema/Result/Log.pm
@@ -25,6 +25,15 @@ __PACKAGE__->add_columns(
     ),
 );
 
+sub anonymized_ip {
+    my $self = shift;
+
+    my $ip = $self->ip;
+    $ip =~ s/\d+\.\d+$/X.X/;
+
+    return $ip;
+}
+
 __PACKAGE__->set_primary_key('id');
 
 1;
diff --git a/lib/PZ/Schema/Result/Shortcut.pm b/lib/PZ/Schema/Result/Shortcut.pm
index e5219d1..54d39d6 100644
--- a/lib/PZ/Schema/Result/Shortcut.pm
+++ b/lib/PZ/Schema/Result/Shortcut.pm
@@ -28,6 +28,7 @@ __PACKAGE__->add_columns(
         code
         title
         description
+        counter
     ),
 );
 
diff --git a/openapi.yaml b/openapi.yaml
index c620092..1dac0c8 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -3,7 +3,7 @@ openapi: 3.0.3
 info:
   title: PiratskĂ˝ zkracovaÄŤ
   description:  PiratskĂ˝ zkracovaÄŤ API
-  version: 1.0.0
+  version: 1.4.0
   license:
     name: Artistic License 2.0
     url: https://www.perlfoundation.org/artistic-license-20.html
@@ -38,6 +38,9 @@ components:
           type: integer
           description: Kód přesměrování
           enum: [301, 302]
+        counter:
+          type: integer
+          readOnly: true
     StatisticItem:
       type: object
       properties:
@@ -78,7 +81,7 @@ paths:
         - Token: []
       summary: "Pridat zkratku"
       operationId: ShortcutCreate
-      x-mojo-to: shortcut#create1
+      x-mojo-to: shortcut#create
       requestBody:
         required: true
         content:
diff --git a/sql/migrations.sql b/sql/migrations.sql
index 0a31e8d..6b1bb5c 100644
--- a/sql/migrations.sql
+++ b/sql/migrations.sql
@@ -71,3 +71,6 @@ from "log"
     group by "shortcut_id", date_trunc('day', "time")
     order by date_trunc('day', "time")
 ;
+
+-- 6 up
+alter table "shortcuts" add column "counter" integer not null default 0;
diff --git a/templates/index.html.ep b/templates/index.html.ep
index e968304..0e41678 100644
--- a/templates/index.html.ep
+++ b/templates/index.html.ep
@@ -8,14 +8,12 @@ který připojí ke své vstupní URL, takže výsledná adresa je mnohem kratš
 použije (např. na ni přejde prohlížečem), zjistí nejprve zkracovač podle použitého kódu z databáze původní
 odkaz a následně návštěvníka na původní adresu přesměruje, uživatel tak téměř nepozná rozdíl oproti
 užití celé původní adresy.
-% if ( ! $c->is_user_authenticated ) {
-Pro použiti Pirátského zkracovače musíte se přihliásit <strong><a href="<%= $c->oidc->authorize %>">Pirátskou identitou</a></strong>
+% if ( ! is_user_authenticated ) {
+Pro použiti Pirátského zkracovače musíte se přihliásit <strong><a href="<%= oidc->authorize %>">Pirátskou identitou</a></strong>
 % }
 </p>
 </div>
 
-% if ( $c->is_user_authenticated ) {
-<div class="content-block">
-%= include 'shortcut/form';
-</div>
+% if ( is_user_authenticated ) {
+%= include 'shortcuts';
 % }
diff --git a/templates/layouts/default.html.ep b/templates/layouts/default.html.ep
index 6a96274..44d69cd 100644
--- a/templates/layouts/default.html.ep
+++ b/templates/layouts/default.html.ep
@@ -22,14 +22,14 @@
   <title><%= config->{name} %></title>
   <link rel="manifest" href="/manifest.json"/>
   <link rel="stylesheet" href="<%= config->{styleguide} %>css/styles.css"/>
+
   <script src="https://cdn-unpkg.pirati.cz/jquery@3.6.0/dist/jquery.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
-  <script src="https://cdn-unpkg.pirati.cz/htmx.org@1.7.0" integrity="sha384-EzBXYPt0/T6gxNp0nuPtLkmRpmDBbjg6WmCUZRLXBBwYYmwAUxzlSGej0ARHX0Bo" crossorigin="anonymous"></script>
+  <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.css" />
+  <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid-theme.min.css" />
+  <script type="text/javascript" src="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.js"></script>
 
- <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.css" />
- <link type="text/css" rel="stylesheet" href="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid-theme.min.css" />
- <script type="text/javascript" src="https://cdn-cdnjs-cloudflare.pirati.cz/ajax/libs/jsgrid/1.5.3/jsgrid.min.js"></script>
- <script src="https://cdn-unpkg.pirati.cz/vue@2.7.8/dist/vue.min.js"></script>
- <script src="<%= config->{styleguide} %>js/main.bundle.js"></script>
+  <script src="https://cdn-unpkg.pirati.cz/vue@2.7.8/dist/vue.min.js"></script>
+  <script src="<%= config->{styleguide} %>js/main.bundle.js"></script>
   <link rel="stylesheet" href="/custom.css"/>
 
 </head>
@@ -41,10 +41,8 @@
       <div>
         <div class="container container--wide navbar__content" :class="{'navbar__content--initialized': true}">
           <div class="navbar__brand my-4 flex items-center lg:pr-8 lg:my-0">
-            <a href="#">
-              <img src="<%= config->{styleguide} %>/images/logo-round-white.svg" class="w-8" />
-            </a>
-            <span class="pl-4 font-bold text-xl lg:pr-8"><%= config->{name} %></span>
+            <a href="/"><img src="<%= config->{styleguide} %>/images/logo-round-white.svg" class="w-8" /></a>
+            <span class="pl-4 font-bold text-xl lg:pr-8"><a href="/"><%= config->{name} %></a></span>
           </div>
           <div class="navbar__menutoggle my-4 flex justify-end lg:hidden">
             <a href="#" @click="show = !show" class="no-underline hover:no-underline">
@@ -54,17 +52,6 @@
           <div v-if="show || isLgScreenSize" class="navbar__main navbar__section navbar__section--expandable container-padding--zero lg:container-padding--auto flex items-center">
            <div class="flex-grow">
 
-% if ( is_user_authenticated ) {
-            <ul class="navbar-menu text-white">
-              <li class="navbar-menu__item">
-                <a href="/" class="navbar-menu__link">Nová zkratka</a>
-              </li>
-              <li class="navbar-menu__item">
-                <a href="/shortcuts" class="navbar-menu__link">Moje zkratky</a>
-              </li>
-            </ul>
-% }
-
             </div>
             <div class="flex items-center space-x-4">
 % if ( is_user_authenticated ) {
diff --git a/templates/log.csv.html.ep b/templates/log.csv.html.ep
index 588624b..9050f20 100644
--- a/templates/log.csv.html.ep
+++ b/templates/log.csv.html.ep
@@ -1,2 +1,2 @@
-"datum a ÄŤas","ip adresa","browser","referrer"<%= "\x0D\x0A" =%><% while (my $entry =  $log->next) { =%>"<%= $entry->time =%>","<%= $entry->ip =%>","<%= $entry->ua =%>","<%= $entry->referrer =%>"<%= "\x0D\x0A" =%><% } =%>
+"datum a ÄŤas","ip adresa","browser","referrer"<%= "\x0D\x0A" =%><% while (my $entry =  $log->next) { =%>"<%= $entry->time =%>","<%= $entry->anonymized_ip =%>","<%= $entry->ua =%>","<%= $entry->referrer =%>"<%= "\x0D\x0A" =%><% } =%>
 
diff --git a/templates/shortcut.html.ep b/templates/shortcut.html.ep
index 2081706..49f2f26 100644
--- a/templates/shortcut.html.ep
+++ b/templates/shortcut.html.ep
@@ -1,67 +1,11 @@
 % layout 'default';
+<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
+<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
 
-<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Zkratka "<%= $shortcut->shortcut %>"</h1>
-% if (0) {
-<h2 class="head-alt-sm my-4">Editace zkratky</h2>
-
-<form id="Edit" class="mb-8">
-
-<div class="form-field form-field--error form-field--required mb-4">
-  <label class="form-field__label" for="url">URL</label>
-  <div class="form-field__wrapper form-field__wrapper--shadowed">
-    <input type="text" class="text-input form-field__control form-field--required" value="" v-model="shortcut.url" id="url" />
-  </div>
-  <div class="form-field__error" v-if="shortcut.url === ''"></div>
-</div>
-
-<div class="form-field form-field--error form-field--required mb-4">
-  <label class="form-field__label" for="code">Typ přesměrování</label>
-  <div class="form-field__wrapper form-field__wrapper--shadowed select">
-    <select v-model="shortcut.code"  class="select__control form-field__control ">
-        <option value="301">301 trvalé</option>
-        <option value="302">302 dočasné</option>
-    </select>
-  </div>
-  <div class="form-field__error" v-if="shortcut.url === ''"></div>
+<div id="App">
+<h1 class="head-base mb-8">Zkratka "{{ shortcut.shortcut }} ({{ shortcut.url }})"</h1>
 </div>
 
-<button class="btn Xbtn--blue-300 btn--hoveractive" @click.prevent="updateShortcut">
- <div class="btn__body">Uložit změny</div>
-</button>
-<button class="btn Xbtn--red-600 btn--hoveractive" @click.prevent="delete_confirm_visible=true">
- <div class="btn__body">Smazat zkratku</div>
-</button>
-
-<div class="modal__overlay toggle-modal-sample-1" id="modal-sample-1" v-if="delete_confirm_visible" v-cloak>
-  <div class="modal__content" role="dialog">
-    <div class="modal__container w-full max-w-2xl" role="dialog">
-      <div class="modal__container-body elevation-10">
-        <button class="modal__close" title="Zavřít" @click.prevent="delete_confirm_visible = false"><i class="ico--cross"></i></button>
-        <div class="card ">
-          <div class="card__body ">
-            <h1 class="card-headline mb-2">Smazat zratku</h1>
-            <p class="card-body-text my-8">
-               Opravdu chcete smazat zkratku?
-            </p>
-
-            <p class="card-body-text">
-              <button class="btn btn--green-300 btn--hoveractive text-sm" @click.prevent="deleteMeet()">
-               <div class="btn__body"><i class="btn__inline-icon ico--bin"></i>ANO</div>
-              </button>
-              <button class="btn btn--orange-400 btn--hoveractive text-sm" @click.prevent="delete_confirm_visible = false">
-               <div class="btn__body"><i class="btn__inline-icon ico--cross"></i>NE</div>
-              </button>
-            </p>
-
-          </div>
-        </div>
-
-      </div>
-    </div>
-  </div>
-</div>
-</form>
-% }
 <h2 class="head-alt-sm my-4">Statistika navštěv</h2>
 <div class="mb-8" style="width:100%; height: 600px;"><canvas id="stat"></canvas></div>
 
@@ -70,16 +14,20 @@
     <h2 class="head-alt-sm">Log navštěv</h2>
     <div>
         <button class="btn btn--orange-400 btn--hoveractive text-sm">
-            <div class="btn__body"><i class="btn__inline-icon ico--download"></i><a href="/shortcuts/<%= $shortcut->id %>/log.csv">Stahnout CSV</a></div>
+            <div class="btn__body"><i class="btn__inline-icon ico--download"></i><a href="/shortcuts/<%= stash->{id} %>/log.csv">Stahnout CSV</a></div>
         </button>
    </div>
 </div>
 <div id="log"></div>
 
-<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
-<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
-
 <script>
+  const BASE_URL = "/api/shortcuts/<%= stash->{id} %>";
+  const API_HEADERS     = {
+      "Content-Type": "application/json",
+      "Accept": "application/json",
+      "X-Auth-Token": "<%= current_user->token %>",
+  };
+
   const end = Date.now();
   const begin = Date.now() - 31 * 24 * 3600 * 1000;
 
@@ -125,17 +73,7 @@
     }
   });
 
-  fetch('/api/shortcuts/<%= $shortcut->id %>/stat/?begin=' + begin + '&end=' + end , {
-    headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' },
-  })
-  .then((response) => response.json())
-  .then((data) => {
-        chart.data.datasets[0].data = data;
-        chart.update();
-  }
-  );
-
-  $("#log").jsGrid({
+  const log = $("#log").jsGrid({
       width: "100%",
       height: "auto",
 
@@ -150,9 +88,9 @@
           loadData: function(filter) {
 
               return $.ajax({
-                  url: "/api/shortcuts/<%= $shortcut->id %>/log/",
+                  url: BASE_URL + "/log/",
+                  headers: API_HEADERS,
                   dataType: "json",
-                  headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' },
                   data: filter,
               });
           },
@@ -165,28 +103,16 @@
       ]
   });
 
-// form
-% if (0) {
-
-
-  const BASE_URL = "/api/shortcuts";
-  const API_HEADERS     = {
-      "Content-Type": "application/json",
-      "Accept": "application/json",
-      "X-Auth-Token": "<%= current_user->token %>",
-  };
-
-  var form = new Vue({
-    el: '#Edit',
+  var app = new Vue({
+    el: '#App',
 
     data: {
-        shortcut: [],
-        delete_confirm_visible: false,
+        shortcut: {}
     },
 
     methods: {
         fetchData: function() {
-          fetch(BASE_URL + '/<%= $shortcut->id %>', {
+          fetch(BASE_URL, {
             headers: API_HEADERS,
           })
             .then((res) => res.json())
@@ -194,13 +120,21 @@
                 this.shortcut = res;
             })
         },
+
+
     }
   });
 
-  form.fetchData();
-% }
-</script>
-
+  app.fetchData();
 
+  fetch(BASE_URL + '/stat/?begin=' + begin + '&end=' + end , {
+    headers: API_HEADERS,
+  })
+  .then((response) => response.json())
+  .then((data) => {
+        chart.data.datasets[0].data = data;
+        chart.update();
+  }
+  );
 
 </script>
diff --git a/templates/shortcut/created.html.ep b/templates/shortcut/created.html.ep
deleted file mode 100644
index 2eea1a6..0000000
--- a/templates/shortcut/created.html.ep
+++ /dev/null
@@ -1,14 +0,0 @@
-<p>
-<div class="card elevation-4">
-  <div class="card__body">
-<p>
-Zkrácený URL: <strong><a href="<%= $url %>"><%= $url %></a></strong>
-</p>
-
-<p>
-<img src="/<%= $shortcut->shortcut %>/qr.png">
-</p>
-
-  </div>
-</div>
-</p>
diff --git a/templates/shortcut/form.html.ep b/templates/shortcut/form.html.ep
deleted file mode 100644
index 1c84a69..0000000
--- a/templates/shortcut/form.html.ep
+++ /dev/null
@@ -1,30 +0,0 @@
-<form hx-post="/" hx-target="#response" hx-params="*">
-
- <div class="card elevation-4 space-y-4 mt-2">
-  <div class="card__body">
-   <div class="grid grid-cols-12 gap-4 row-gap-6">
-
-<div class="form-field col-span-9">
-  <label class="form-field__label" for="url">URL</label>
-  <div class="form-field__wrapper form-field__wrapper--shadowed">
-    <input type="text" name="url" class="text-input form-field__control" value="" placeholder="https://www.pirati.cz/program/dlouhodoby/psychotropni-latky/" required="required" />
-  </div>
-</div>
-
-<div class="form-field col-span-3">
-  <label class="form-field__label" for="shortcut">Zkratka</label>
-  <div class="form-field__wrapper form-field__wrapper--shadowed">
-    <input type="text" name="shortcut" class="text-input form-field__control w-40" size="8" maxlength="8" value="" placeholder="thc" />
-    <button class="btn btn--grey-125 btn--hoveractive ml-4">
-        <div class="btn__body">Zkrátit</div>
-    </button>
-  </div>
-</div>
-
- </div>
- </div>
-</div>
-
-</form>
-
-<div id="response"></div>
diff --git a/templates/shortcut/invalid.html.ep b/templates/shortcut/invalid.html.ep
deleted file mode 100644
index d21bf2b..0000000
--- a/templates/shortcut/invalid.html.ep
+++ /dev/null
@@ -1,4 +0,0 @@
-<span class="alert alert--red-600 alert--faded">
-  <span><%= $error %></span>
-</span>
-
diff --git a/templates/shortcuts-vue.html.ep b/templates/shortcuts-vue.html.ep
deleted file mode 100644
index e94e200..0000000
--- a/templates/shortcuts-vue.html.ep
+++ /dev/null
@@ -1,53 +0,0 @@
-% layout 'default';
-
-<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Moje zkratky</h1>
-
-<table id="Shortcuts" class="table table-fixed table--striped" v-cloak>
-  <thead>
-    <tr>
-      <th>Zkratka</th>
-      <th>URL</th>
-      <th>Přesměrování</th>
-    </tr>
-  </thead>
-  <tbody>
-  <tr v-for="shortcut in shortcuts" class="cursor-pointer" @click="window.location.href ='/shortcuts/' + shortcut.id">
-    <td class="head-xs">{{shortcut.shortcut}}</td>
-    <td class="w-96">{{ shortcut.url }}</td>
-    <td class="">{{ shortcut.code == 301 ? '301 trvalé' : '302 dočasné'}}</td>
-   </tr>
-  </tbody>
-
-</table>
-
-<script type="module">
-  const BASE_URL = "/api/shortcuts";
-  const API_HEADERS     = {
-      "Content-Type": "application/json",
-      "Accept": "application/json",
-      "X-Auth-Token": "<%= current_user->token %>",
-  };
-
-  var list = new Vue({
-    el: '#Shortcuts',
-
-    data: {
-        shortcuts: [],
-    },
-
-    methods: {
-        fetchData: function() {
-          fetch(BASE_URL, {
-            headers: API_HEADERS,
-          })
-            .then((res) => res.json())
-            .then(res => {
-                this.shortcuts = res;
-            })
-        },
-    }
-  });
-
-  list.fetchData();
-
-</script>
diff --git a/templates/shortcuts.html.ep b/templates/shortcuts.html.ep
index 0ddf381..53c0b2a 100644
--- a/templates/shortcuts.html.ep
+++ b/templates/shortcuts.html.ep
@@ -1,100 +1,268 @@
-% layout 'default';
+<div id="App">
+<form @submit.prevent="addShortcut" v-cloak>
 
-<h1 class="head-alt-md md:head-alt-lg max-w-5xl mb-8">Moje zkratky</h1>
+ <div class="card elevation-4 space-y-2 my-4">
+  <div class="card__body">
+   <div class="grid grid-cols-12 gap-4 row-gap-6">
 
-<div id="jsGrid"></div>
+    <div class="form-field col-span-9">
+      <label class="form-field__label" for="url">URL</label>
+      <div class="form-field__wrapper form-field__wrapper--shadowed">
+        <input type="text" name="url" class="text-input form-field__control" value="" placeholder="https://www.pirati.cz/program/dlouhodoby/psychotropni-latky/" required="required" v-model="shortcut.url" @focus="formError=''"/>
+      </div>
+    </div>
 
-<script>
+    <div class="form-field col-span-3">
+      <label class="form-field__label" for="shortcut">Zkratka</label>
+      <div class="form-field__wrapper form-field__wrapper--shadowed">
+        <input type="text" name="shortcut" class="text-input form-field__control w-40" size="8" maxlength="8" value="" placeholder="thc"  v-model="shortcut.shortcut" @focus="formError=''"/>
+        <button class="btn btn--grey-125 btn--hoveractive ml-4">
+            <div class="btn__body">Zkrátit</div>
+        </button>
+      </div>
+    </div>
 
-jsGrid.validators.url = {
-    message: 'Zadejte platnĂ˝ URL',
-    validator: function(value, item) {
-        let url;
+    </div><%# grid %>
+   </div><%# card__body %>
+  </div><%# card %>
+ <div  v-if="formError" class="my-4"><span class="alert alert--red-600 alert--faded">{{ formError }}</span></div>
+</form>
 
-        try {
-            url = new URL(value);
-        } catch (_) {
-            return false;
-        }
+<table class="table table--striped table--bordered table-auto w-full" v-cloak>
+  <thead>
+    <tr>
+      <th class="text-left w-16">Zkratka</th>
+      <th class="text-left">URL</th>
+      <th class="text-left w-16">Přesměrování</th>
+      <th class="text-left w-16">Kliky</th>
+      <th class="text-left w-40"></th>
+    </tr>
+  </thead>
+  <tbody>
+  <tr v-for="shortcut in shortcuts" >
+    <td class="text-bold" @click="showEdit(shortcut)" >{{shortcut.shortcut}}</td>
+    <td v-bind:title="shortcut.url" @click="showEdit(shortcut)" >{{ stripURL(shortcut.url) }}</td>
+    <td @click="showEdit(shortcut)">{{ shortcut.code == 301 ? '301 trvalé' : '302 dočasné'}}</td>
+    <td @click="showEdit(shortcut)" class="text-right">{{shortcut.counter}}</td>
+    <td>
+        <i class="ico--link cursor-pointer mx-1" @click="showInfo(shortcut)" title="Zkopirovat odkaz"></i>
+        <i class="ico--calendar cursor-pointer mx-1" @click="window.location.href ='/shortcut/' + shortcut.id" title="Statistika"></i>
+        <i class="ico--equalizer cursor-pointer mx-1" @click="showEdit(shortcut)" title="Editovat"></i>
+        <i class="ico--bin cursor-pointer mx-1" @click="showDelete(shortcut)" title="Smazat"></i>
+    </td>
+   </tr>
+  </tbody>
+</table>
 
-        return url.protocol === "http:" || url.protocol === "https:";
-    }
-}
-
-$(function() {
-    $("#jsGrid").jsGrid({
-        width: "100%",
-        height: "auto",
-
-        inserting: false,
-        editing: true,
-        sorting: true,
-        paging: true,
-        selecting: false,
-        autoload: true,
-        invalidMessage: 'Neplatná data',
-        deleteConfirm: 'Opravdu chcete smazat zkratku?',
-
-        controller: {
-            loadData: function(filter) {
-
-                return $.ajax({
-                    url: "/api/shortcuts",
-                    dataType: "json",
-                    headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' },
-                    data: filter,
-                });
-            },
-
-            insertItem: function(item) {
-                return $.ajax({
-                    type: "POST",
-                    dataType: "json",
-                    headers: { 'X-Auth-Token': '<%= $c->current_user->token %>'  },
-                    url: "/api/shortcuts",
-                    data: JSON.stringify(item)
-                });
-            },
-
-            updateItem: function(item) {
-                return $.ajax({
-                    type: "PUT",
-                    dataType: "json",
-                    headers: { 'X-Auth-Token': '<%= $c->current_user->token %>'  },
-                    url: "/api/shortcuts/" + item.id,
-                    data: JSON.stringify(item)
-                });
-            },
-
-            deleteItem: function(item) {
-                return $.ajax({
-                    type: "DELETE",
-                    headers: { 'X-Auth-Token': '<%= $c->current_user->token %>' },
-                    url: "/api/shortcuts/" + item.id,
-                });
-            },
+<div class="modal__overlay" v-if="shortcutInfoVisible" v-cloak>
+  <div class="modal__content" role="dialog">
+    <div class="modal__container" role="dialog">
+      <div class="modal__container-body elevation-10">
+        <button class="modal__close" title="Zavřít" @click="shortcutInfoVisible = false"><i class="ico--cross"></i></button>
+        <div class="card">
+          <div class="card__body w-80 text-center">
+            <p class="card-body-text">Zkratka byla zkopírovana do schránky. Pro její sdílení můžete používat i QR kód</p>
+            <img v-bind:src="'/' + selectedShortcut.shortcut + '/qr.png'" class="mx-auto my-8">
+            <b>{{ selectedShortcut.full_url }}</b>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="modal__overlay" v-if="deleteConfirmVisible">
+  <div class="modal__content" role="dialog">
+    <div class="modal__container" role="dialog">
+      <div class="modal__container-body elevation-10">
+        <button class="modal__close" title="Zavřít" @click.prevent="deleteConfirmVisible = false"><i class="ico--cross"></i></button>
+        <div class="card">
+          <div class="card__body">
+            <h1 class="card-headline mb-2">Smazat zkratku</h1>
+            <p class="card-body-text my-8">Opravdu chcete smazat zkratku?</p>
+
+            <p class="card-body-text">
+              <button class="btn btn--red-600 btn--hoveractive text-sm" @click.prevent="deleteShortcut(selectedShortcut)">
+               <div class="btn__body"><i class="btn__inline-icon ico--bin"></i>ANO</div>
+              </button>
+              <button class="btn btn--grey-500 btn--hoveractive text-sm" @click.prevent="deleteConfirmVisible = false">
+               <div class="btn__body"><i class="btn__inline-icon ico--cross"></i>NE</div>
+              </button>
+            </p>
+
+          </div>
+        </div>
+
+      </div>
+    </div>
+  </div>
+</div>
+
+<div class="modal__overlay" v-if="shortcutEditVisible">
+  <div class="modal__content" role="dialog">
+    <div class="modal__container w-full max-w-2xl" role="dialog">
+      <div class="modal__container-body elevation-10">
+        <button class="modal__close" title="Zavřít" @click.prevent="shortcutEditVisible = false"><i class="ico--cross"></i></button>
+        <div class="card">
+          <div class="card__body">
+            <h1 class="card-headline mb-2">Editovat zkratku</h1>
+
+                <form class="my-4">
+
+                    <div class="form-field form-field--error form-field--required mb-4">
+                      <label class="form-field__label" for="url">URL</label>
+                      <div class="form-field__wrapper form-field__wrapper--shadowed">
+                        <input type="text" class="text-input form-field__control form-field--required" value="" v-model="selectedShortcut.url" id="url" />
+                      </div>
+                      <div class="form-field__error" v-if="selectedShortcut.url === ''"></div>
+                    </div>
+
+                    <div class="form-field form-field--error form-field--required mb-4">
+                      <label class="form-field__label" for="code">Typ přesměrování</label>
+                      <div class="form-field__wrapper form-field__wrapper--shadowed select">
+                        <select v-model="selectedShortcut.code"  class="select__control form-field__control ">
+                            <option value="301">301 trvalé</option>
+                            <option value="302">302 dočasné</option>
+                        </select>
+                      </div>
+                      <div class="form-field__error" v-if="shortcut.url === ''"></div>
+                    </div>
+
+                    <button class="btn btn--grey-500 btn--hoveractive" @click.prevent="updateShortcut">
+                     <div class="btn__body">Uložit změny</div>
+                    </button>
+                </form>
 
+          </div>
+        </div>
+
+      </div>
+    </div>
+  </div>
+</div>
+
+</div>
+
+<script type="module">
+  const BASE_URL = "/api/shortcuts";
+  const API_HEADERS     = {
+      "Content-Type": "application/json",
+      "Accept": "application/json",
+      "X-Auth-Token": "<%= current_user->token %>",
+  };
+
+  var app = new Vue({
+    el: '#App',
+
+    data: {
+        shortcuts: [],
+        deleteConfirmVisible: false,
+        shortcutInfoVisible: false,
+        shortcutEditVisible: false,
+        selectedShortcut: {},
+
+        shortcut: {},
+        formError: '',
+    },
+
+    methods: {
+        fetchData: function() {
+          fetch(BASE_URL, {
+            headers: API_HEADERS,
+          })
+            .then((res) => res.json())
+            .then(res => {
+                this.shortcuts = res;
+            })
         },
 
-        fields: [
-            { name: "shortcut", title: "Zkratka", type: "text", width: '5em', editing: false },
-            { name: "url", title: "URL", type: "text", width: '100%', validate: "url" },
-            { name: "code", title: "Typ přesměrování", type: "select", width: '10em',  items: [
-                { name: "302 dočasné", code: 302 },
-                { name: "301 trvalé", code: 301 },
-            ],
-                valueField: "code",
-                textField: "name"
-            },
-            { type: "text", title: "Statistika", name: "id", editing: false,
-              itemTemplate: function(value) {
-                 return '<a href="/shortcuts/' + value + '">' + value + '</a>';
-              },
-             },
-             { type: "control", editButton: false, width: 60 },
-
-        ]
-    });
-});
-</script>
+        addShortcut: function() {
 
+          fetch(BASE_URL, {
+            method: "POST",
+            headers: API_HEADERS,
+            body: JSON.stringify(this.shortcut),
+          })
+            .then( response => {
+              if ( response.status == 201 ) {
+                this.shortcut = {}
+                app.fetchData();
+              }
+              else {
+                response.json().then(json => {
+                    this.formError = json.errors[0]['message']
+                })
+              }
+            })
+            .catch(() => {
+              this.formError = "ERROR_SERVERSIDE"
+            });
+        },
+
+        updateShortcut: function() {
+
+          fetch(BASE_URL + '/' + this.selectedShortcut.id, {
+            method: "PUT",
+            headers: API_HEADERS,
+            body: JSON.stringify(this.selectedShortcut),
+          })
+            .then( response => {
+              if ( response.status == 204 ) {
+                this.shortcutEditVisible = false;
+                app.fetchData();
+              }
+              else {
+                response.json().then(json => {
+                    this.editError = json.errors[0]['message']
+                })
+              }
+            })
+            .catch(() => {
+              this.editError = "ERROR_SERVERSIDE"
+            });
+        },
+
+        deleteShortcut: function() {
+          if ( ! this.selectedShortcut ) {
+            return true;
+          }
+
+          fetch(BASE_URL + '/' + this.selectedShortcut.id, {
+            method: "DELETE",
+            headers: API_HEADERS,
+          })
+            .then( response => {
+                this.deleteConfirmVisible = false;
+                app.fetchData();
+            })
+        },
+
+        stripURL: function(url) {
+            const urlObj = new URL(url);
+            urlObj.search = '';
+            urlObj.hash = '';
+            return urlObj.toString();
+        },
+
+        showDelete: function(shortcut) {
+            this.selectedShortcut = shortcut;
+            this.deleteConfirmVisible = true;
+        },
+
+        showInfo: function(shortcut) {
+            this.selectedShortcut = shortcut;
+            this.selectedShortcut.full_url = 'https://<%= config->{domain} %>/'+ shortcut.shortcut ;
+            this.shortcutInfoVisible = true;
+            navigator.clipboard.writeText(this.selectedShortcut.full_url);
+        },
+
+        showEdit: function(shortcut) {
+            this.selectedShortcut = shortcut;
+            this.shortcutEditVisible = true;
+        },
+    }
+  });
+
+  app.fetchData();
+
+</script>
-- 
GitLab