summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKevin Chabowski <kevin@kch42.de>2014-03-19 22:45:31 +0100
committerKevin Chabowski <kevin@kch42.de>2014-03-19 22:45:31 +0100
commitefbdc3bbd6cba8691391ec2192ea96adbfb8c029 (patch)
treebe6a69fab84d15d9fc1f8a50a02e8d57e9388c3b
downloadtodo-efbdc3bbd6cba8691391ec2192ea96adbfb8c029.tar.gz
todo-efbdc3bbd6cba8691391ec2192ea96adbfb8c029.tar.bz2
todo-efbdc3bbd6cba8691391ec2192ea96adbfb8c029.zip
Initial commit
-rwxr-xr-x.htaccess17
-rw-r--r--LICENSE4
-rw-r--r--README.markdown38
-rw-r--r--autoload/Todo/Lists.php141
-rw-r--r--autoload/Todo/Model/TodoList.php100
-rw-r--r--autoload/Todo/Model/User.php66
-rw-r--r--autoload/Todo/SessionHelper.php49
-rw-r--r--autoload/Todo/UserManager.php203
-rwxr-xr-xconfig.ini7
-rwxr-xr-xindex.php41
-rw-r--r--ui/blank.html0
-rw-r--r--ui/deleteAccount.html6
-rw-r--r--ui/deleteList.html8
-rw-r--r--ui/img/sprites.pngbin0 -> 924 bytes
-rw-r--r--ui/img/sprites.xcfbin0 -> 13642 bytes
-rw-r--r--ui/list.html46
-rw-r--r--ui/login.html8
-rw-r--r--ui/mails/activationcode6
-rw-r--r--ui/mails/pwreset11
-rw-r--r--ui/master.html63
-rw-r--r--ui/noListSelected.html1
-rw-r--r--ui/pwreset.html7
-rw-r--r--ui/pwresetRequest.html6
-rw-r--r--ui/register.html10
-rw-r--r--ui/settings.html8
-rw-r--r--ui/style.css306
-rw-r--r--ui/welcome.html3
27 files changed, 1155 insertions, 0 deletions
diff --git a/.htaccess b/.htaccess
new file mode 100755
index 0000000..68d1f73
--- /dev/null
+++ b/.htaccess
@@ -0,0 +1,17 @@
+# Enable rewrite engine and route requests to framework
+RewriteEngine On
+
+# Some servers require you to specify the `RewriteBase` directive
+# In such cases, it should be the path (relative to the document root)
+# containing this .htaccess file
+#
+# RewriteBase /
+
+RewriteCond %{REQUEST_URI} \.ini$
+RewriteRule \.ini$ - [R=404]
+
+RewriteCond %{REQUEST_FILENAME} !-l
+RewriteCond %{REQUEST_FILENAME} !-f
+RewriteCond %{REQUEST_FILENAME} !-d
+RewriteRule .* index.php [L,QSA]
+RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization},L]
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c74f13c
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,4 @@
+ DO WHATEVER THE FUCK YOU WANT, PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. You just DO WHATEVER THE FUCK YOU WANT.
diff --git a/README.markdown b/README.markdown
new file mode 100644
index 0000000..14fe1cc
--- /dev/null
+++ b/README.markdown
@@ -0,0 +1,38 @@
+# todo
+
+A simple todo web application.
+
+## Install
+
+First, you need to fetch some dependencies:
+
+### Fat-Free Framework
+
+Download the Fat-Free Framework from [http://fatfreeframework.com/](http://fatfreeframework.com/) and extract the content of the `lib` folder into the `lib` folder of this application.
+
+### DejaVu Sans ExtraLight
+
+Get this light front from [http://www.fonts2u.com/download/dejavu-sans-extralight.font-face](http://www.fonts2u.com/download/dejavu-sans-extralight.font-face) and extract the files into the `ui/DejaVu_Sans_ExtraLight` directory
+
+Now that all dependencies are fetched, you can copy the application to your webserver. Just one thing: Change the permissions of the `tmp` directory so the webserver can write to it.
+
+## Configuration
+
+Todo is configured with the `config.ini` file.
+
+* `DEBUG` – Unless you intend to further develop this application, leave this to `0`
+* `sql_dsn` – The [PDO DSN]() for connecting to the database
+* `sql_user` – Username for the database access
+* `sql_pass` – Password for the database access
+* `mail_from` – Outgoing mails (confirmation codes, password reset mails) will use this E-Mail address for the `From` field
+* `appname` – The name of the application
+
+### Protecting the configuration file
+
+It is usually a bad idea that the config file is accessible via the web, since people can then see the username and password of your database.
+
+One possibility to prevent this, is to move the file into a directory that can not be accessed via the web and then modifying `index.php`: Search for the line `$f3->config('config.ini');` and change the `config.ini` part to point to the new location.
+
+## Or just use my public installation
+
+[todo.kch42.net](http://todo.kch42.net/)
diff --git a/autoload/Todo/Lists.php b/autoload/Todo/Lists.php
new file mode 100644
index 0000000..9f951a3
--- /dev/null
+++ b/autoload/Todo/Lists.php
@@ -0,0 +1,141 @@
+<?php
+
+namespace Todo;
+
+class Lists extends SessionHelper {
+ public function home($f3) {
+ $f3->set('content', ($this->user === NULL) ? 'login.html' : 'noListSelected.html');
+ }
+
+ private function populateListData($f3, $l) {
+ $f3->set('listid', $l->id);
+ $f3->set('listname', $l->name);
+ $f3->set('listdata', $l->itemsToArray());
+ }
+
+ private function getList($f3, $id) {
+ $l = new \Todo\Model\TodoList($f3->get('DB'));
+ if((!$l->byID($id)) || $l->user !== $this->user->id) {
+ $f3->set('content', 'blank.html');
+ $f3->set('error', 'List not found');
+ $f3->error(404); # TODO: Meh, this stops F3 from rendering the template....
+ return NULL;
+ }
+ return $l;
+ }
+
+ public function deleteList($f3, $args) {
+ if($this->needLogin($f3)) {
+ return;
+ }
+
+ if(!($l = $this->getList($f3, $args['list']))) {
+ return;
+ }
+
+ if(($f3->get('VERB') === 'POST') && ($f3->get('POST.confirm') === 'OK')) {
+ $l->deleteList();
+
+ $f3->set('success', 'List deleted');
+ $f3->set('content', 'blank.html');
+ } else {
+ $this->populateListData($f3, $l);
+ $f3->set('content', 'deleteList.html');
+ }
+ }
+
+ public function newList($f3) {
+ if($this->needLogin($f3)) {
+ return;
+ }
+
+ if($f3->get('VERB') !== 'POST') {
+ return;
+ }
+
+ if($f3->get('POST.listname') === '') {
+ $f3->set('error', 'List name must not be empty');
+ $f3->set('content', 'blank.html');
+ return;
+ }
+
+ $l = new \Todo\Model\TodoList($f3->get('DB'));
+ $l->user = $this->user->id;
+ $l->name = $f3->get('POST.listname');
+ $l->save();
+
+ $f3->set('success', 'List created!');
+
+ $f3->set('content', 'list.html');
+ $this->populateListData($f3, $l);
+ }
+
+ public function showList($f3, $args) {
+ if($this->needLogin($f3)) {
+ return;
+ }
+
+ $f3->set('content', 'list.html');
+
+ if(!($l = $this->getList($f3, $args['list']))) {
+ return;
+ }
+
+ if($f3->get('VERB') === 'POST') {
+ $ok = true;
+ switch($f3->get('POST.action')) {
+ case 'setname':
+ if($f3->get('POST.name') === '') {
+ $f3->set('error', 'List name must not be empty');
+ $ok = false;
+ } else {
+ $l->name = $f3->get('POST.name');
+ $l->save();
+ }
+ break;
+ case 'additem':
+ if($f3->get('POST.itemtext') === '') {
+ $f3->set('error', 'Can not add empty list item');
+ $ok = false;
+ } else {
+ $l->addItem($f3->get('POST.itemtext'));
+ }
+ break;
+ case 'delitem':
+ $l->delItem($f3->get('POST.id'));
+ break;
+ case 'setchecked':
+ $it = $l->getItem($f3->get('POST.id'));
+ if($it === NULL) {
+ $f3->set('error', 'Invalid item ID');
+ $ok = false;
+ } else {
+ $it->checked = ($f3->get('POST.checked') === 'y');
+ $it->save();
+ }
+ break;
+ case 'moveup':
+ $l->moveItem($f3->get('POST.id'), -1);
+ break;
+ case 'movedown':
+ $l->moveItem($f3->get('POST.id'), 1);
+ break;
+ case 'movex':
+ $l->moveItem($f3->get('POST.id'), $f3->get('POST.move'));
+ break;
+ case 'delchecked':
+ $l->deleteChecked();
+ break;
+ default:
+ $ok = false;
+ $f3->set('error', 'Unknown list action.');
+ }
+
+ if($ok) {
+ $f3->set('success', 'List updated');
+ }
+ }
+
+ $this->populateListData($f3, $l);
+ }
+} \ No newline at end of file
diff --git a/autoload/Todo/Model/TodoList.php b/autoload/Todo/Model/TodoList.php
new file mode 100644
index 0000000..4470c34
--- /dev/null
+++ b/autoload/Todo/Model/TodoList.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Todo\Model;
+
+class TodoList extends \DB\SQL\Mapper {
+ public function __construct($db) {
+ parent::__construct($db, 'lists');
+ }
+
+ public function byID($id) {
+ $this->load(array('id=?', $id));
+ return !$this->dry();
+ }
+
+ public function itemsToArray() {
+ $items = array();
+ $it = new \DB\SQL\Mapper($this->db, 'items');
+ for($it->load(array('list=?', $this->id), array('order' => 'ord ASC')); !$it->dry(); $it->next()) {
+ $items[] = array(
+ 'id' => $it->id,
+ 'ord' => $it->ord,
+ 'text' => $it->text,
+ 'date' => $it->date,
+ 'checked' => $it->checked,
+ );
+ }
+
+ return $items;
+ }
+
+ public function countItems() {
+ $it = new \DB\SQL\Mapper($this->db, 'items');
+ return $it->count(array('list=?', $this->id));
+ }
+
+ public function getItem($id) {
+ $it = new \DB\SQL\Mapper($this->db, 'items');
+ $it->load(array('list=? AND id=?', $this->id, $id));
+ return $it->dry() ? NULL : $it;
+ }
+
+ public function moveItem($id, $movement) {
+ $it = new \DB\SQL\Mapper($this->db, 'items');
+ $it->load(array('list=? AND id=?', $this->id, $id));
+ if($it->dry()) {
+ return;
+ }
+ $ordOld = $it->ord;
+ $it->reset();
+
+ $ordNew = min(max(0, $ordOld + $movement), $this->countItems() - 1);
+ if($ordNew === $ordOld) {
+ return;
+ }
+
+ $moveSgn = ($movement > 0) ? 1 : -1;
+
+ if($moveSgn === 1) {
+ $it->load(array('list=? AND ord>? AND ord<=?', $this->id, $ordOld, $ordNew));
+ } else {
+ $it->load(array('list=? AND ord>=? AND ord<?', $this->id, $ordNew, $ordOld));
+ }
+ for(;!$it->dry(); $it->next()) {
+ $it->ord -= $moveSgn;
+ $it->save();
+ }
+
+ $it->reset();
+ $it->load(array('list=? AND id=?', $this->id, $id));
+ $it->ord = $ordNew;
+ $it->save();
+ }
+
+ public function addItem($text) {
+ $n = $this->countItems();
+
+ $now = new \DateTime('now');
+
+ $it = new \DB\SQL\Mapper($this->db, 'items');
+ $it->text = $text;
+ $it->ord = $n;
+ $it->date = $now->format('Y-m-d H:i:s');
+ $it->checked = false;
+ $it->list = $this->id;
+ $it->save();
+ }
+
+ public function delItem($id) {
+ $this->db->exec('DELETE FROM `items` WHERE `list` = :l AND `id` = :i', array(':l' => $this->id, ':i' => $id));
+ }
+
+ public function deleteList() {
+ $this->db->exec('DELETE FROM `items` WHERE `list` = :l', array(':l' => $this->id));
+ $this->erase();
+ }
+
+ public function deleteChecked() {
+ $this->db->exec('DELETE FROM `items` WHERE `list` = :l AND `checked` = :c', array(':l' => $this->id, ':c' => true));
+ }
+} \ No newline at end of file
diff --git a/autoload/Todo/Model/User.php b/autoload/Todo/Model/User.php
new file mode 100644
index 0000000..53c8f67
--- /dev/null
+++ b/autoload/Todo/Model/User.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Todo\Model;
+
+class User extends \DB\SQL\Mapper {
+ public function __construct(\DB\SQL $db) {
+ parent::__construct($db, 'users');
+ }
+
+ public function makeCode() {
+ $alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
+ $len = strlen($alphabet);
+ $this->code = '';
+ for($i = 0; $i < 16; $i++) {
+ $this->code .= substr($alphabet, mt_rand(0, $len-1), 1);
+ }
+ }
+
+ public function register($name, $email, $password) {
+ $this->load(array('name=? OR email=?', $name, $email));
+ if(!$this->dry()) {
+ $this->reset();
+ return false;
+ }
+
+ $this->name = $name;
+ $this->email = $email;
+ $this->pwhash = \Bcrypt::instance()->hash($password);
+ $this->active = false;
+ $this->makeCode();
+ $this->save();
+ return true;
+ }
+
+ public function byName($name) {
+ $this->load(array('name=?', $name));
+ return !$this->dry();
+ }
+
+ public function byID($id) {
+ $this->load(array('id=?', $id));
+ return !$this->dry();
+ }
+
+ public function byEmail($email) {
+ $this->load(array('email=?', $email));
+ return !$this->dry();
+ }
+
+ public function verifyPass($password) {
+ return \Bcrypt::instance()->verify($password, $this->pwhash);
+ }
+
+ public function deleteUser() {
+ $this->db->exec('DELETE FROM `items` WHERE `list` IN (SELECT `id` FROM `lists` WHERE `user` = :u)', array(':u' => $this->id));
+ $this->db->exec('DELETE FROM `lists` WHERE `user` = :u', array(':u' => $this->id));
+ $this->erase();
+ }
+
+ # Returns a TodoList object that can be iterated with ->next().
+ public function lists() {
+ $l = new \Todo\Model\TodoList($this->db);
+ $l->load(array('user=?', $this->id), array('order' => 'name ASC'));
+ return $l;
+ }
+} \ No newline at end of file
diff --git a/autoload/Todo/SessionHelper.php b/autoload/Todo/SessionHelper.php
new file mode 100644
index 0000000..b4811ab
--- /dev/null
+++ b/autoload/Todo/SessionHelper.php
@@ -0,0 +1,49 @@
+<?php
+
+namespace Todo;
+
+abstract class SessionHelper {
+ protected $user = NULL;
+
+ private function populateLists($f3) {
+ $lists = array();
+ for($l = $this->user->lists(); !$l->dry(); $l->next()) {
+ $lists[] = array(
+ 'id' => $l->id,
+ 'name' => $l->name,
+ 'active' => ($l->id === $f3->get('listid')),
+ );
+ }
+ $f3->set('lists', $lists);
+ }
+
+ public function beforeRoute($f3) {
+ $id = $f3->get('SESSION.userID');
+ if(is_numeric($id) && ($id > 0)) {
+ $this->user = new \Todo\Model\User($f3->get('DB'));
+ if((!$this->user->byID($id)) || (!$this->user->active)) {
+ $this->user = NULL;
+ }
+ }
+ $f3->set('listid', -1);
+ }
+
+ public function afterRoute($f3) {
+ $f3->set('SESSION.userID', ($this->user === NULL) ? -1 : $this->user->id);
+ if($this->user !== NULL) {
+ $f3->set('user', $this->user->name);
+ $this->populateLists($f3);
+ } else {
+ $f3->set('user', '');
+ }
+ }
+
+ protected function needLogin($f3) {
+ if($this->user === NULL) {
+ $f3->set('error', 'You must be logged in to do that');
+ $f3->set('content', 'blank.html');
+ return true;
+ }
+ return false;
+ }
+} \ No newline at end of file
diff --git a/autoload/Todo/UserManager.php b/autoload/Todo/UserManager.php
new file mode 100644
index 0000000..306eb02
--- /dev/null
+++ b/autoload/Todo/UserManager.php
@@ -0,0 +1,203 @@
+<?php
+
+namespace Todo;
+
+class UserManager extends \Todo\SessionHelper {
+ public function login($f3, $args) {
+ if($this->user !== NULL) {
+ $f3->set('info', 'You are already logged in');
+ $f3->set('content', 'blank.html');
+ return;
+ }
+ $f3->set('content', 'login.html');
+
+ if($f3->get('VERB') !== "POST") {
+ return;
+ }
+
+ $this->user = new \Todo\Model\User($f3->get('DB'));
+ if($this->user->byName($f3->get('POST.name')) && $this->user->verifyPass($f3->get('POST.password')) && $this->user->active) {
+ $f3->set('success', 'Login Successful!');
+ $f3->set('content', 'noListSelected.html');
+ } else {
+ $this->user = NULL;
+ $f3->set('error', 'Username or password is wrong or account is not active.');
+ }
+ }
+
+ public function logout($f3, $args) {
+ $this->user = NULL;
+ $f3->set('success', 'Logout Successful!');
+ $f3->set('content', 'login.html');
+ }
+
+ public function register($f3, $args) {
+ if($this->user !== NULL) {
+ $f3->set('error', 'You already have an account!');
+ $f3->set('content', 'blank.html');
+ return;
+ }
+ $f3->set('content', 'register.html');
+
+ if($f3->get('VERB') !== 'POST') {
+ return;
+ }
+
+ if(($f3->get('POST.password') === '') || ($f3->get('POST.name') === '') || ($f3->get('POST.email') === '')) {
+ $f3->set('error', 'All form inputs must be filled out');
+ return;
+ }
+
+ if($f3->get('POST.password') !== $f3->get('POST.repeatPassword')) {
+ $f3->set('error', 'Passwords do not match');
+ return;
+ }
+
+ $u = new \Todo\Model\User($f3->get('DB'));
+ if($u->register($f3->get('POST.name'), $f3->get('POST.email'), $f3->get('POST.password'))) {
+ $f3->set('username', $u->name);
+ $f3->set('activationurl', 'http://'.$f3->get('HOST').$f3->get('BASE').'/activate/'.$u->id.'/'.$u->code);
+ mail($u->email, 'Activation code for ' . $f3->get('appname'), \Template::instance()->render('mails/activationcode', 'text/plain'), 'From:'.$f3->get('mail_from'));
+
+ $f3->set('success', 'New account created. Check your mails for the activation link.');
+ $f3->set('content', 'blank.html');
+ } else {
+ $f3->set('error', 'Name or E-Mail already in use.');
+ }
+ }
+
+ public function delete($f3, $args) {
+ if($this->needLogin($f3)) {
+ return;
+ }
+
+ $f3->set('content', 'deleteAccount.html');
+
+ if(($f3->get('VERB') == 'POST') && ($f3->get('POST.confirm') == 'OK')) {
+ $this->user->deleteUser();
+ $this->user = NULL;
+ $f3->set('success', 'Account deleted!');
+ $f3->set('content', 'blank.html');
+ }
+ }
+
+ public function activate($f3, $args) {
+ $f3->set('content', 'blank.html');
+
+ $u = new \Todo\Model\User($f3->get('DB'));
+ if(!$u->byID($args['user'])) {
+ $f3->set('error', 'Unknown user.');
+ return;
+ }
+
+ if($u->active) {
+ $f3->set('info', 'Account already activated');
+ return;
+ }
+
+ if($u->code !== $args['code']) {
+ $f3->set('error', 'Wrong activation code!');
+ return;
+ }
+
+ $u->active = true;
+ $u->makeCode(); # set a new random code to prevent double usage
+ $u->save();
+
+ $f3->set('success', 'Account activated!');
+ }
+
+ public function initResetpw($f3, $args) {
+ $f3->set('content', 'pwresetRequest.html');
+
+ if($f3->get('VERB') !== 'POST') {
+ return;
+ }
+
+ $u = new \Todo\Model\User($f3->get('DB'));
+ if(!$u->byEmail($f3->get('POST.email'))) {
+ $f3->set('error', 'No account with this address registered.');
+ return;
+ }
+
+ $u->makeCode();
+ $u->save();
+
+ $f3->set('username', $u->name);
+ $f3->set('reseturl', 'http://'.$f3->get('HOST').$f3->get('BASE').'/pwreset/'.$u->id.'/'.$u->code);
+ mail($u->email, 'Password reset for ' . $f3->get('appname'), \Template::instance()->render('mails/pwreset', 'text/plain'), 'From:'.$f3->get('mail_from'));
+
+ $f3->set('success', 'Password reset link was sent to your E-Mail address.');
+ $f3->set('content', 'blank.html');
+ }
+
+ public function resetpw($f3, $args) {
+ $u = new \Todo\Model\User($f3->get('DB'));
+ if((!$u->byID($args['user'])) || ($u->code !== $args['code'])) {
+ $f3->set('error', 'Invalid password reset link.');
+ $f3->set('content', 'blank.html');
+ return;
+ }
+
+ $f3->set('content', 'pwreset.html');
+ if($f3->get('VERB') !== 'POST') {
+ return;
+ }
+
+ if($f3->get('POST.password') !== $f3->get('POST.repeatPassword')) {
+ $f3->set('error', 'Passwords do not match');
+ return;
+ }
+
+ $u->pwhash = \Bcrypt::instance()->hash($f3->get('POST.password'));
+ $u->makeCode();
+ $u->save();
+
+ $f3->set('success', 'Password changed');
+ $f3->set('content', 'blank.html');
+ }
+
+ public function settings($f3, $args) {
+ if($this->needLogin($f3)) {
+ return;
+ }
+
+ $f3->set('content', 'settings.html');
+ $f3->set('email', $this->user->email);
+
+ if($f3->get('VERB') !== 'POST') {
+ return;
+ }
+
+ $ok = array();
+ $error = array();
+
+ if(($this->user->email !== $f3->get('POST.email')) && ($f3->get('POST.email') !== '')) {
+ $this->user->email = $f3->get('POST.email');
+ $f3->set('email', $this->user->email);
+ $ok[] = 'E-Mail address changed.';
+ }
+
+ if($f3->get('POST.password') !== '') {
+ if($f3->get('POST.password') === $f3->get('POST.repeatPassword')) {
+ $this->user->pwhash = \Bcrypt::instance()->hash($f3->get('POST.password'));
+ $ok[] = 'Password changed.';
+ } else {
+ $error[] = 'Passwords do not match.';
+ }
+ }
+
+ if(!empty($ok)) {
+ $this->user->save();
+ }
+
+ $text = array_reduce(array_merge($ok, $error), function($a, $b) { return $a . ' ' . $b; });
+ if((!empty($ok)) && (!empty($error))) {
+ $f3->set('info', $text);
+ } else if(!empty($ok)) {
+ $f3->set('success', $text);
+ } else if(!empty($error)) {
+ $f3->set('error', $text);
+ }
+ }
+}
diff --git a/config.ini b/config.ini
new file mode 100755
index 0000000..b1f4e23
--- /dev/null
+++ b/config.ini
@@ -0,0 +1,7 @@
+[globals]
+DEBUG=0
+sql_dsn="mysql:host=localhost;dbname=DATABASE"
+sql_user=USER
+sql_pass=PASSWORD
+mail_from=no-reply@your-domain
+appname=Todo
diff --git a/index.php b/index.php
new file mode 100755
index 0000000..fc9e213
--- /dev/null
+++ b/index.php
@@ -0,0 +1,41 @@
+<?php
+
+$f3 = require('lib/base.php');
+
+$f3->set('AUTOLOAD','autoload/');
+$f3->set('UI', 'ui/');
+
+$f3->config('config.ini');
+
+# Init DB
+$db = new DB\SQL($f3->get('sql_dsn'), $f3->get('sql_user'), $f3->get('sql_pass'));
+$f3->set("DB", $db);
+
+# Init Sessions
+new Db\SQL\Session($db);
+
+# Homepage
+$f3->route('GET @root: /', 'Todo\Lists->home');
+
+# User management stuff
+$f3->route('GET|POST @login: /login', 'Todo\UserManager->login');
+$f3->route('GET @logout: /logout', 'Todo\UserManager->logout');
+$f3->route('GET|POST @register: /register', 'Todo\UserManager->register');
+$f3->route('GET|POST @delete_acc: /delete_acc', 'Todo\UserManager->delete');
+$f3->route('GET /activate/@user/@code', 'Todo\UserManager->activate');
+$f3->route('GET|POST @resetpw: /resetpw', 'Todo\UserManager->initResetpw');
+$f3->route('GET|POST /resetpw/@user/@code', 'Todo\UserManager->resetpw');
+$f3->route('GET|POST @settings: /settings', 'Todo\UserManager->settings');
+
+# List stuff
+$f3->route('POST @newlist: /newlist', 'Todo\Lists->newList');
+$f3->route('GET|POST /list/@list', 'Todo\Lists->showList');
+$f3->route('GET|POST /list/@list/delete', 'Todo\Lists->deleteList');
+
+$f3->route('GET /foo', function($f3) {
+ $f3->set('content', 'blank.html');
+ $f3->set('info', 'http://'.$f3->get('HOST').$f3->get('BASE'));
+});
+
+$f3->run();
+echo Template::instance()->render('master.html');
diff --git a/ui/blank.html b/ui/blank.html
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/ui/blank.html
diff --git a/ui/deleteAccount.html b/ui/deleteAccount.html
new file mode 100644
index 0000000..565082f
--- /dev/null
+++ b/ui/deleteAccount.html
@@ -0,0 +1,6 @@
+<form action="{{ @BASE . @ALIASES.delete_acc }}" method="post">
+ <input type="hidden" name="confirm" value="OK" />
+ <p>Do you really want to delete your account?</p>
+ <p><a href="{{ @BASE . @ALIASES.root }}">No</a></p>
+ <p><input type="submit" value="Yes" /></p>
+</form> \ No newline at end of file
diff --git a/ui/deleteList.html b/ui/deleteList.html
new file mode 100644
index 0000000..a71af7f
--- /dev/null
+++ b/ui/deleteList.html
@@ -0,0 +1,8 @@
+<form action="{{ @BASE }}/list/{{ @listid }}/delete" method="post">
+ <input type="hidden" name="confirm" value="OK" />
+ <p>Do you really want to delete the list "{{ @listname }}"?</p>
+ <p>
+ <a class="fakebutton" href="{{ @BASE . @ALIASES.root }}">No</a>
+ <button type="submit">Yes</button>
+ </p>
+</form> \ No newline at end of file
diff --git a/ui/img/sprites.png b/ui/img/sprites.png
new file mode 100644
index 0000000..b3af1e4
--- /dev/null
+++ b/ui/img/sprites.png
Binary files differ
diff --git a/ui/img/sprites.xcf b/ui/img/sprites.xcf
new file mode 100644
index 0000000..830a6a5
--- /dev/null
+++ b/ui/img/sprites.xcf
Binary files differ
diff --git a/ui/list.html b/ui/list.html
new file mode 100644
index 0000000..ce998dd
--- /dev/null
+++ b/ui/list.html
@@ -0,0 +1,46 @@
+<set myurl="{{ @BASE . '/list/' . @listid }}" />
+<form action="{{ @myurl }}" method="post">
+ <h1>
+ <input type="hidden" name="action" value="setname" />
+ <input type="text" name="name" value="{{ @listname }}" class="listname" />
+ <button type="submit" title="Set name">✓</button>
+ </h1>
+</form>
+
+<check if="{{ empty(@listdata) }}">
+ <true><div class="empty">List is empty</div></true>
+ <false>
+ <ul class="todolist">
+ <repeat group="{{ @listdata }}" value="{{ @it }}">
+ <li{{ @it.checked ? ' class="checked"' : '' }}>
+ <form action="{{ @myurl }}" method="post">
+ <input type="hidden" name="checked" value="{{ @it.checked ? 'n' : 'y' }}" />
+ <button type="submit" name="action" value="setchecked" class="chkbtn {{ @it.checked ? 'checked' : '' }}"><input type="checkbox" name="checked_now" disabled="disabled" {{ @it.checked ? 'checked="checked"' : '' }} value="y" /></button>
+
+ <span class="txt">{{ @it.text }}</span>
+
+ <input type="hidden" name="id" value="{{ @it.id }}" />
+ <span class="btncluster">
+ <button type="submit" name="action" value="moveup" title="Move up">↑</button>
+ <button type="submit" name="action" value="movedown" title="Move down">↓</button>
+ <button type="submit" name="action" value="delitem" title="Delete">X</button>
+ </span>
+ </form>
+ </li>
+ </repeat>
+ </ul>
+ <div class="delchecked">
+ <form action="{{ @myurl }}" method="post">
+ <button type="submit" name="action" value="delchecked">Delete checked items</button>
+ </form>
+ </div>
+ </false>
+</check>
+
+<div class="additem">
+ <form action="{{ @myurl }}/" method="post">
+ <input type="hidden" name="action" value="additem" />
+ <input type="text" name="itemtext" />
+ <button type="submit" title="Add item">+</button>
+ </form>
+</div> \ No newline at end of file
diff --git a/ui/login.html b/ui/login.html
new file mode 100644
index 0000000..0b8d2e4
--- /dev/null
+++ b/ui/login.html
@@ -0,0 +1,8 @@
+<h1>Login</h1>
+
+<form action="{{ @BASE . @ALIASES.login }}" method="post" class="tabform">
+ <p><label for="login_name">User:</label> <input id="login_name" type="text" name="name" value="" /></p>
+ <p><label for="login_pass">Password:</label> <input id="login_pass" type="password" name="password" /></p>
+ <p class="formctrl"><input type="submit" /></p>
+</form>
+<p><a href="{{ @BASE . @ALIASES.resetpw }}">Forgot Password?</a> | <a href="{{ @BASE . @ALIASES.register }}">Register a new account</a></p> \ No newline at end of file
diff --git a/ui/mails/activationcode b/ui/mails/activationcode
new file mode 100644
index 0000000..bb46bfd
--- /dev/null
+++ b/ui/mails/activationcode
@@ -0,0 +1,6 @@
+Hi, {{ @username }}!
+
+You have registered an account for {{ @appname }}.
+To activate the account, visit this URL:
+
+{{ @activationurl }} \ No newline at end of file
diff --git a/ui/mails/pwreset b/ui/mails/pwreset
new file mode 100644
index 0000000..2c69c37
--- /dev/null
+++ b/ui/mails/pwreset
@@ -0,0 +1,11 @@
+Hi, {{ @username }}!
+
+You have requested a password reset four your account on
+{{ @appname }}.
+
+To reset you password, use this URL:
+
+{{ @reseturl }}
+
+If you did NOT request a password reset, you can simply ignore this
+email.
diff --git a/ui/master.html b/ui/master.html
new file mode 100644
index 0000000..25775f9
--- /dev/null
+++ b/ui/master.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Todo</title>
+ <link rel="stylesheet" type="text/css" href="{{ @BASE }}/ui/style.css" />
+</head>
+<body>
+ <div id="headerwrap"><div id="header">
+ <a href="{{ @BASE . @ALIASES.root }}" id="appname">{{ @appname }}</a>
+ <span id="userinfo">
+ <check if="{{ empty(@user) }}">
+ <true>
+ <a href="{{ @BASE . @ALIASES.login }}">Login</a>
+ </true>
+ <false>
+ Logged in as <span class="username">{{ @user }}</span>.
+ <a href="{{ @BASE . @ALIASES.settings }}">Settings</a>
+ <a href="{{ @BASE . @ALIASES.logout }}">Logout</a>
+ </false>
+ </check>
+ </span>
+ </div></div>
+
+ <div class="bghelp"></div>
+ <div id="lists">
+ <check if="{{ !empty(@user) }}">
+ <ul>
+ <check if="{{ !empty(@lists) }}">
+ <true>
+ <repeat group="{{ @lists }}" value="{{ @l }}">
+ <li{{ @l.active ? ' class="active"' : '' }}>
+ <a href="{{ @BASE . '/list/' . @l.id }}" class="listlink">{{ @l.name }}</a>
+ <a class="deletelist" href="{{ @BASE . '/list/' . @l.id }}/delete" title="delete">X</a>
+ </li>
+ </repeat>
+ </true>
+ <false>
+ <li class="empty">No lists here. Why not add one below?</li>
+ </false>
+ </check>
+ </ul>
+
+ <form id="addlist" action="{{ @BASE . @ALIASES.newlist }}" method="post">
+ <input type="text" name="listname" /><button type="submit">+</button>
+ </form>
+ </check>
+ </div>
+
+ <div id="content">
+ <check if="{{ !empty(@success) }}">
+ <div class="success notify"><span class="icon"></span>{{ @success }}</div>
+ </check>
+ <check if="{{ !empty(@error) }}">
+ <div class="error notify"><span class="icon"></span>{{ @error }}</div>
+ </check>
+ <check if="{{ !empty(@info) }}">
+ <div class="info notify"><span class="icon"></span>{{ @info }}</div>
+ </check>
+
+ <include href="{{ @content }}" />
+ </div>
+</body>
+</html> \ No newline at end of file
diff --git a/ui/noListSelected.html b/ui/noListSelected.html
new file mode 100644
index 0000000..053b8e3
--- /dev/null
+++ b/ui/noListSelected.html
@@ -0,0 +1 @@
+<div class="nolist">No list selected</div> \ No newline at end of file
diff --git a/ui/pwreset.html b/ui/pwreset.html
new file mode 100644
index 0000000..8d563d0
--- /dev/null
+++ b/ui/pwreset.html
@@ -0,0 +1,7 @@
+<h1>Password reset</h1>
+
+<form action="{{ @URI }}" method="post">
+ <p>New Password: <input type="password" name="password" /></p>
+ <p>Retype Password: <input type="password" name="repeatPassword" /></p>
+ <p><input type="submit" /></p>
+</form> \ No newline at end of file
diff --git a/ui/pwresetRequest.html b/ui/pwresetRequest.html
new file mode 100644
index 0000000..a6c51b3
--- /dev/null
+++ b/ui/pwresetRequest.html
@@ -0,0 +1,6 @@
+<h1>Password reset</h1>
+
+<form action="{{ @BASE . @ALIASES.resetpw }}" method="post" class="tabform">
+ <p><label for="pwreset_mail">E-Mail:</label> <input id="pwreset_mail" type="text" name="email" /></p>
+ <p class="formctrl"><input type="submit" /></p>
+</form> \ No newline at end of file
diff --git a/ui/register.html b/ui/register.html
new file mode 100644
index 0000000..8686600
--- /dev/null
+++ b/ui/register.html
@@ -0,0 +1,10 @@
+<h1>Register</h1>
+
+<form action="{{ @BASE . @ALIASES.register }}" method="post" class="tabform">
+ <p><label for="reg_name">Name:</label> <input id="reg_name" type="text" name="name" value="" /></p>
+ <p><label for="reg_mail">E-Mail:</label> <input id="reg_mail" type="text" name="email" value="" /></p>
+ <p><label for="reg_pass">Password:</label> <input id="reg_pass" type="password" name="password" /></p>
+ <p><label for="reg_repeat">Repeat Password:</label> <input id="reg_repeat" type="password" name="repeatPassword" /></p>
+ <p class="formctrl"><input type="submit" /></p>
+</form>
+<p>Already have an account? Go to <a href="{{ @BASE . @ALIASES.login }}">Login</a>.</p> \ No newline at end of file
diff --git a/ui/settings.html b/ui/settings.html
new file mode 100644
index 0000000..47f3b1b
--- /dev/null
+++ b/ui/settings.html
@@ -0,0 +1,8 @@
+<h1>Account Settings</h1>
+
+<form action="{{ @BASE . @ALIASES.settings }}" method="post" class="tabform">
+ <p><label for="accset_mail">New E-Mail Address:</label> <input id="accset_mail" type="text" name="email" value="{{ @email }}" /></p>
+ <p><label for="accset_newpw">New Password:</label> <input id="accset_newpw" type="password" name="password" /></p>
+ <p><label for="accset_repeatpw">Retype Password:</label> <input id="accset_repeatpw"type="password" name="repeatPassword" /></p>
+ <p class="formctrl"><input type="submit" /></p>
+</form> \ No newline at end of file
diff --git a/ui/style.css b/ui/style.css
new file mode 100644
index 0000000..18ae794
--- /dev/null
+++ b/ui/style.css
@@ -0,0 +1,306 @@
+@import url("DejaVu_Sans_ExtraLight/stylesheet.css");
+* {
+ font-family: sans-serif;
+}
+html, body {
+ padding: 0px;
+ margin: 0px;
+}
+body {
+ background: white;
+ font-size: 12pt;
+}
+
+input[type="text"], input[type="password"] {
+ background: #fff;
+ border: 1px solid #666;
+ font-size: 12pt;
+ padding: 0.5ex 0ex 0.5ex;
+}
+input[type="text"]:hover, input[type="password"]:hover {
+ border-color: #000;
+}
+button, .fakebutton {
+ background: #eee;
+ border: 1px solid #ccc;
+ font-size: 12pt;
+ padding: 0.5ex 0ex 0.5ex;
+ cursor: pointer;
+}
+button:hover, .fakebutton:hover {
+ background-color: #ccc !important;
+}
+a.fakebutton {
+ color: black;
+ text-decoration: none;
+ display: inline-block;
+}
+
+#headerwrap {
+ position: fixed;
+ top: 0px;
+ width: 100%;
+ background: #aaa;
+ color: #eee;
+ margin: 0px;
+ z-index: 1000;
+}
+
+#header {
+ margin: 0px 7mm 0px;
+}
+
+#header a {
+ display: inline-block;
+ color: #eee;
+ text-decoration: none;
+ border-bottom: 3px solid #bbb;
+ height: 100%;
+ vertical-align: middle;
+ padding: 0.5ex 0ex 0.5ex;
+}
+#header a:hover {
+ color: #fff;
+ border-bottom-color: #55c5ff !important;
+}
+
+#userinfo {
+ position: absolute;
+ right: 7mm;
+}
+.username {
+ color: white;
+}
+
+.bghelp {
+ z-index: -1;
+ background: #eee;
+ position: fixed;
+ margin: 0;
+ padding: 0;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 60mm;
+}
+
+#lists {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 60mm;
+ z-index: 1;
+ padding: 0mm;
+ background: #eee;
+}
+
+#lists ul {
+ list-style: none;
+ margin: 3.5ex 0mm 3em;
+ padding: 0px;
+}
+
+#lists ul li {
+ position: relative;
+ margin: 0;
+ padding: 0.5ex 3ex 0.5ex 1ex;
+}
+#lists ul li:hover {
+ background: #f8f8f8;
+}
+#lists ul li.active {
+ background: #fff;
+}
+#lists a {
+ color: #555;
+ text-decoration: none;
+}
+#lists a:hover {
+ color: black;
+}
+.listlink {
+ display: block;
+}
+
+
+.deletelist {
+ display: inline-block;
+ margin:0;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ width: 3ex;
+ border-left: 1px dotted #555;
+ text-align: center;
+ padding: 0.5ex 0ex 0.5ex 0ex;
+}
+
+.deletelist:hover {
+ background: #ccc;
+}
+
+#addlist {
+ position: fixed;
+ bottom: 0px;
+ width: 58mm;
+ background: #ddd;
+ padding: 1mm;
+ vertical-align: middle;
+}
+#addlist button {
+ position: absolute;
+ right: 1mm;
+ width: 7mm;
+ margin: 0mm;
+}
+#addlist input {
+ width: 100%;
+ margin: 0mm;
+ width: 50mm;
+}
+
+#content {
+ padding: 4ex 2mm 2mm 63mm;
+}
+
+h1 {
+ font-family: "DejaVu Sans ExtraLight", sans-serif;
+ font-weight: bold;
+ font-size: 16pt;
+}
+
+h1 input.listname {
+ border: 1px solid #ccc;
+ font-size: 16pt;
+ margin: 0;
+ font-family: "DejaVu Sans ExtraLight", sans-serif;
+ font-weight: bold;
+}
+h1 input.listname:hover, h1 input.listname:focus {
+ border: 1px solid black;
+}
+h1 button {
+ background-color: #f7f7f7;
+ color: #ddd;
+ border: 1px solid #ddd;
+}
+h1:hover button {
+ background: #eee;
+ border: 1px solid #ccc;
+ color: black;
+}
+
+ul.todolist {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+ul.todolist li {
+ margin: 0;
+ position: relative;
+ padding: 0.6ex 0 0.6ex;
+ border-top: 1px solid #f7f7f7;
+ min-height: 34px
+}
+ul.todolist li:first-child {
+ border-top: none;
+}
+ul.todolist form {
+ display: inline;
+}
+li.checked span.txt {
+ font-style: italic;
+ color: gray;
+ text-decoration: line-through;
+ vertical-align: middle;
+}
+ul.todolist span.btncluster {
+ vertical-align: middle;
+ position: absolute;
+ right: 0;
+}
+ul.todolist span.btncluster button {
+ background: #fbfbfb;
+ border: 1px solid #f2f2f2;
+ color: #bbb;
+}
+ul.todolist li:hover span.btncluster button {
+ background: #eee;
+ border: 1px solid #ccc;
+ color: black;
+}
+
+.chkbtn {
+ padding: 0;
+ cursor: pointer;
+ width: 20px;
+ height: 20px;
+ vertical-align: middle;
+}
+.chkbtn.checked {
+ background-image: url(img/sprites.png);
+}
+.chkbtn input {
+ display: none;
+}
+
+.notify {
+ margin: -100px auto 1mm;
+ width: 50%;
+ text-align: center;
+ color: white;
+ padding: 100px 5px 5px 35px;
+ position: relative;
+}
+
+.notify .icon {
+ display: block;
+ width: 20px;
+ height: 100%;
+ position: absolute;
+ left: 5px;
+ top: 45px;
+ background-size: 80px 20px;
+ background-repeat: no-repeat;
+ background-image: url(img/sprites.png);
+}
+
+.success {background: #2a0;}
+.success .icon {background-position: left -20px top 50%;}
+.info {background: #48f;}
+.info .icon {background-position: left -40px top 50%;}
+.error {background: #f40;}
+.error .icon {background-position: left -60px top 50%;}
+
+.delchecked {
+ text-align: right;
+}
+
+.empty {
+ font-style: italic;
+ margin: 2mm;
+}
+.nolist {
+ font-style: italic;
+ font-size: 18pt;
+ color: #999;
+ text-align: center;
+ position: absolute;
+ top: 40%;
+}
+
+form.tabform {
+ display: table;
+}
+form.tabform p {
+ display: table-row;
+}
+form.tabform input, form.tabform label {
+ display: table-cell;
+ margin: 1mm;
+}
+form.tabform p.formctrl {
+ display: block;
+ margin: 0;
+}
diff --git a/ui/welcome.html b/ui/welcome.html
new file mode 100644
index 0000000..3b110bb
--- /dev/null
+++ b/ui/welcome.html
@@ -0,0 +1,3 @@
+<h1>Welcome</h1>
+
+<p>Foo!</p> \ No newline at end of file