changeset 587:d4a01a023998

admin: Add "FoodTruck" admin panel from the side experiment project.
author Ludovic Chabant <ludovic@chabant.com>
date Sat, 16 Jan 2016 14:24:35 -0800
parents 59268b4d8c71
children b884bef3e611
files .hgignore MANIFEST.in foodtruck.py foodtruck/__init__.py foodtruck/config.py foodtruck/main.py foodtruck/scm/__init__.py foodtruck/scm/base.py foodtruck/scm/mercurial.py foodtruck/sites.py foodtruck/templates/create_page.html foodtruck/templates/dashboard.html foodtruck/templates/edit_page.html foodtruck/templates/install.html foodtruck/templates/layouts/default.html foodtruck/templates/layouts/master.html foodtruck/templates/layouts/menu.html foodtruck/templates/list_source.html foodtruck/templates/login.html foodtruck/templates/settings.html foodtruck/views/__init__.py foodtruck/views/baking.py foodtruck/views/create.py foodtruck/views/edit.py foodtruck/views/main.py foodtruck/views/menu.py foodtruck/views/preview.py foodtruck/views/settings.py foodtruck/views/sources.py foodtruck/web.py foodtruckui/assets/img/foodtruck.png foodtruckui/assets/js/foodtruck.js foodtruckui/assets/raw/foodtruck.psd foodtruckui/assets/sass/foodtruck.scss foodtruckui/assets/sass/foodtruck/_base.scss foodtruckui/assets/sass/foodtruck/_editing.scss foodtruckui/assets/sass/foodtruck/_sidebar.scss foodtruckui/bower.json foodtruckui/gulpfile.js foodtruckui/package.json foodtruckui/tests/__init__.py foodtruckui/tests/test_config.py setup.py
diffstat 40 files changed, 1901 insertions(+), 2 deletions(-) [+]
line wrap: on
line diff
--- a/.hgignore	Sun Jan 10 10:51:11 2016 -0800
+++ b/.hgignore	Sat Jan 16 14:24:35 2016 -0800
@@ -1,16 +1,18 @@
 syntax: glob
 *.pyc
 *.egg-info
+__pycache__
 venv
 tags
+bower_components
+node_modules
 build/lib
 util/messages/_cache
 util/messages/_counter
 dist
 docs/_cache
 docs/_counter
-docs/bower_components
-docs/node_modules
+foodtruck/static
 piecrust.egg-info
 piecrust/__version__.py
 .ropeproject
--- a/MANIFEST.in	Sun Jan 10 10:51:11 2016 -0800
+++ b/MANIFEST.in	Sat Jan 16 14:24:35 2016 -0800
@@ -3,6 +3,8 @@
 include LICENSE.rst
 include requirements.txt
 include dev-requirements.txt
+recursive-include foodtruck *.py *.html
+recursive-include foodtruck/static *
 recursive-include piecrust *.py mime.types
 recursive-include piecrust/resources *
 recursive-include tests *.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,6 @@
+from foodtruck.main import main
+
+
+if __name__ == '__main__':
+    main()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/config.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,67 @@
+import os.path
+import copy
+import logging
+import yaml
+from piecrust.configuration import (
+        Configuration, ConfigurationError, ConfigurationLoader,
+        merge_dicts)
+
+
+logger = logging.getLogger(__name__)
+
+
+def get_foodtruck_config():
+    cfg_path = os.path.join(os.getcwd(), 'foodtruck.yml')
+    return FoodTruckConfiguration(cfg_path)
+
+
+class FoodTruckConfigNotFoundError(Exception):
+    pass
+
+
+class FoodTruckConfiguration(Configuration):
+    def __init__(self, cfg_path):
+        super(FoodTruckConfiguration, self).__init__()
+        self.cfg_path = cfg_path
+
+    def _load(self):
+        try:
+            with open(self.cfg_path, 'r', encoding='utf-8') as fp:
+                values = yaml.load(
+                        fp.read(),
+                        Loader=ConfigurationLoader)
+
+            self._values = self._validateAll(values)
+        except OSError:
+            raise FoodTruckConfigNotFoundError()
+        except Exception as ex:
+            raise ConfigurationError(
+                    "Error loading configuration from: %s" %
+                    self.cfg_path) from ex
+
+    def _validateAll(self, values):
+        if values is None:
+            values = {}
+
+        values = merge_dicts(copy.deepcopy(default_configuration), values)
+
+        return values
+
+    def save(self):
+        with open(self.cfg_path, 'w', encoding='utf8') as fp:
+            self.cfg.write(fp)
+
+
+default_configuration = {
+        'triggers': {
+            'bake': 'chef bake'
+            },
+        'scm': {
+            'type': 'hg'
+            },
+        'security': {
+            'username': '',
+            'password': ''
+            }
+        }
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/main.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,54 @@
+import sys
+import logging
+import argparse
+from .web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+            description="FoodTruck command line utility")
+    parser.add_argument(
+            '--debug',
+            help="Show debug information",
+            action='store_true')
+    parser.add_argument(
+            '--version',
+            help="Print version and exit",
+            action='store_true')
+
+    args = parser.parse_args()
+    if args.version:
+        try:
+            from .__version__ import version
+        except ImportError:
+            print("Can't find version information.")
+            args.exit(1)
+        print("FoodTruck %s" % version)
+        args.exit(0)
+
+    root_logger = logging.getLogger()
+    root_logger.setLevel(logging.INFO)
+    if args.debug:
+        root_logger.setLevel(logging.DEBUG)
+
+    log_handler = logging.StreamHandler(sys.stdout)
+    if args.debug:
+        log_handler.setLevel(logging.DEBUG)
+    else:
+        log_handler.setLevel(logging.INFO)
+    root_logger.addHandler(log_handler)
+
+    try:
+        app.run(debug=args.debug, threaded=True)
+    except SystemExit:
+        # This is needed for Werkzeug's code reloader to be able to correctly
+        # shutdown the child process in order to restart it (otherwise, SSE
+        # generators will keep it alive).
+        from .views import baking
+        logger.debug("Shutting down SSE generators from main...")
+        baking.server_shutdown = True
+        raise
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/scm/base.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,18 @@
+
+
+class RepoStatus(object):
+    def __init__(self):
+        self.new_files = []
+        self.edited_files = []
+
+
+class SourceControl(object):
+    def __init__(self, root_dir):
+        self.root_dir = root_dir
+
+    def getStatus(self):
+        raise NotImplementedError()
+
+    def commit(self, paths, author, message):
+        raise NotImplementedError()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/scm/mercurial.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,71 @@
+import os
+import logging
+import tempfile
+import subprocess
+from .base import SourceControl, RepoStatus
+
+
+logger = logging.getLogger(__name__)
+
+
+def _s(strs):
+    """ Convert a byte array to string using UTF8 encoding. """
+    if strs is None:
+        return None
+    assert isinstance(strs, bytes)
+    return strs.decode('utf8')
+
+
+class MercurialSourceControl(SourceControl):
+    def __init__(self, root_dir):
+        super(MercurialSourceControl, self).__init__(root_dir)
+        self.hg = 'hg'
+
+    def getStatus(self):
+        res = RepoStatus()
+        st_out = self._run('status')
+        for line in st_out.split('\n'):
+            if len(line) == 0:
+                continue
+            if line[0] == '?' or line[0] == 'A':
+                res.new_files.append(line[2:])
+            elif line[0] == 'M':
+                res.edited_files.append(line[2:])
+        return res
+
+    def commit(self, paths, author, message):
+        if not message:
+            raise ValueError("No commit message specified.")
+
+        # Check if any of those paths needs to be added.
+        st_out = self._run('status', *paths)
+        add_paths = []
+        for line in st_out.splitlines():
+            if line[0] == '?':
+                add_paths.append(line[2:])
+        if len(add_paths) > 0:
+            self._run('add', *paths)
+
+        # Create a temp file with the commit message.
+        f, temp = tempfile.mkstemp()
+        with os.fdopen(f, 'w') as fd:
+            fd.write(message)
+
+        # Commit and clean up the temp file.
+        try:
+            commit_args = list(paths) + ['-l', temp]
+            if author:
+                commit_args += ['-u', author]
+            self._run('commit', *commit_args)
+        finally:
+            os.remove(temp)
+
+    def _run(self, cmd, *args, **kwargs):
+        exe = [self.hg, '-R', self.root_dir]
+        exe.append(cmd)
+        exe += args
+        logger.debug("Running Mercurial: " + str(exe))
+        out = subprocess.check_output(exe)
+        encoded_out = _s(out)
+        return encoded_out
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/sites.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,136 @@
+import os
+import os.path
+import shlex
+import logging
+import threading
+import subprocess
+from piecrust.app import PieCrust
+
+
+logger = logging.getLogger(__name__)
+
+
+class UnauthorizedSiteAccessError(Exception):
+    pass
+
+
+class Site(object):
+    def __init__(self, name, root_dir, config):
+        self.name = name
+        self.root_dir = root_dir
+        self.config = config
+        self._piecrust_app = None
+        self._scm = None
+        self._bake_thread = None
+        logger.debug("Creating site object for %s" % self.name)
+
+    @property
+    def piecrust_app(self):
+        if self._piecrust_app is None:
+            s = PieCrust(self.root_dir)
+            s.config.set('site/root', '/site/%s/' % self.name)
+            self._piecrust_app = s
+        return self._piecrust_app
+
+    @property
+    def scm(self):
+        if self._scm is None:
+            scm_type = self.config.get('scm/type')
+            if scm_type == 'hg':
+                from .scm.mercurial import MercurialSourceControl
+                self._scm = MercurialSourceControl(self.root_dir)
+            else:
+                raise NotImplementedError()
+        return self._scm
+
+    @property
+    def is_bake_running(self):
+        return self._bake_thread is not None and self._bake_thread.is_alive()
+
+    @property
+    def bake_thread(self):
+        return self._bake_thread
+
+    def bake(self):
+        bake_cmd = self.config.get('triggers/bake')
+        bake_args = shlex.split(bake_cmd)
+
+        logger.debug("Running bake: %s" % bake_args)
+        proc = subprocess.Popen(bake_args, cwd=self.root_dir,
+                                stdout=subprocess.PIPE,
+                                stderr=subprocess.PIPE)
+
+        pid_file_path = os.path.join(self.root_dir, 'foodtruck_bake.pid')
+        with open(pid_file_path, 'w') as fp:
+            fp.write(str(proc.pid))
+
+        logger.debug("Running bake monitor for PID %d" % proc.pid)
+        self._bake_thread = _BakeThread(self.name, self.root_dir, proc,
+                                        self._onBakeEnd)
+        self._bake_thread.start()
+
+    def _onBakeEnd(self):
+        os.unlink(os.path.join(self.root_dir, 'foodtruck_bake.pid'))
+        self._bake_thread = None
+
+
+class _BakeThread(threading.Thread):
+    def __init__(self, sitename, siteroot, proc, callback):
+        super(_BakeThread, self).__init__(
+                name='%s_bake' % sitename, daemon=True)
+        self.sitename = sitename
+        self.siteroot = siteroot
+        self.proc = proc
+        self.callback = callback
+
+        log_file_path = os.path.join(self.siteroot, 'foodtruck_bake.log')
+        self.log_fp = open(log_file_path, 'w', encoding='utf8')
+
+    def run(self):
+        for line in self.proc.stdout:
+            self.log_fp.write(line.decode('utf8'))
+        for line in self.proc.stderr:
+            self.log_fp.write(line.decode('utf8'))
+        self.proc.communicate()
+        if self.proc.returncode != 0:
+            self.log_fp.write("Error, bake process returned code %d" %
+                              self.proc.returncode)
+        self.log_fp.close()
+
+        logger.debug("Bake ended for %s." % self.sitename)
+        self.callback()
+
+
+class FoodTruckSites():
+    def __init__(self, config, current_site=None):
+        self._sites = {}
+        self._site_dirs = {}
+        self.config = config
+        self.current_site = current_site
+
+    def get_root_dir(self, name=None):
+        name = name or self.current_site
+        s = self._site_dirs.get(name)
+        if s:
+            return s
+
+        scfg = self.config.get('sites/%s' % name)
+        root_dir = scfg.get('path')
+        if root_dir is None:
+            raise Exception("Site '%s' has no path defined." % name)
+        if not os.path.isdir(root_dir):
+            raise Exception("Site '%s' has an invalid path." % name)
+        self._site_dirs[name] = root_dir
+        return root_dir
+
+    def get(self, name=None):
+        name = name or self.current_site
+        s = self._sites.get(name)
+        if s:
+            return s
+
+        root_dir = self.get_root_dir(name)
+        s = Site(name, root_dir, self.config)
+        self._sites[name] = s
+        return s
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/create_page.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,35 @@
+{% set title = 'Create' %}
+
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<form action="{{url_postback}}" method="POST" class="ft-create-form">
+<div class="container">
+    {% for field in fields %}
+    <div class="row">
+        <div class="col-md-10 col-md-offset-1">
+            <div class="form-group">
+                <div class="input-group input-group-lg">
+                    <span class="input-group-addon" id="meta-{{field.name}}">{{field.display_name}}</span>
+                    <input type="text" class="form-control" placeholder="{{field.value}}" aria-describedby="meta-{{field.name}}" name="meta-{{field.name}}" />
+                </div>
+            </div>
+        </div>
+    </div>
+
+    {% endfor %}
+
+    <input type="hidden" name="source_name" value="{{source_name}}" />
+
+    <div class="row">
+        <div class="col-md-6 col-md-offset-1">
+            <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a>
+        </div>
+        <div class="col-md-4">
+            <button type="submit" name="do_save" class="btn btn-primary btn-lg pull-right">Create and Edit</button>
+        </div>
+    </div>
+</div>
+</form>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/dashboard.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,60 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="container">
+    <div class="row">
+        <div class="col-md-6">
+            <h2><span class="icon ion-stats-bars"></span> Site Summary</h2>
+            {% for s in sources %}
+            <div class="ft-summary-source">
+                <a href="{{s.list_url}}">{{s.page_count}} {{s.name}}</a>
+            </div>
+            {% endfor %}
+
+            <h2><span class="icon ion-pinpoint"></span> Preview Site</h2>
+            <a href="{{url_preview}}">Preview {{site_name}}</a>
+        </div>
+        <div class="col-md-6">
+            <h2><span class="icon ion-erlenmeyer-flask"></span> Work in Progress</h2>
+            {% if new_pages %}
+            <p>New pages</p>
+            <ul>
+                {% for p in new_pages %}
+                <li><a href="{{p.url}}">{{p.title}}</a><br/>
+                    {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li>
+                {% endfor %}
+            </ul>
+            {% endif %}
+            {% if edited_pages %}
+            <p>Edited pages</p>
+            <ul>
+                {% for p in edited_pages %}
+                <li><a href="{{p.url}}">{{p.title}}</a><br/>
+                    {%if p.text%}<pre>{{p.text}}</pre>{%endif%}</li>
+                {% endfor %}
+            </ul>
+            {% endif %}
+            {% if not new_pages and not edited_pages %}
+            <p>No work in progress.</p>
+            {% endif %}
+
+            <h2><span class="icon"></span> Switch Site</h2>
+            <form action="{{url_switch}}" method="POST">
+                {% for site in sites %}
+                <p><button type="submit" name="site_name" value="{{site.name}}" class="btn btn-link">{{site.display_name}}</a></p>
+                {% endfor %}
+            </form>
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-12">
+            <h2><span class="icon ion-radio-waves"></span> Publishing</h2>
+            <form action="{{url_bake}}" method="POST">
+                <button name="do_bake" type="submit" class="btn btn-primary btn-lg">
+                    <span class="icon ion-upload"></span> Bake!</button>
+            </form>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/edit_page.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,39 @@
+{% set title = 'Write' %}
+
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<form action="{{url_postback}}" method="POST" class="ft-write-form" id="ft-write-form">
+<div class="container">
+    <div class="row">
+        <div class="col-md-10 col-md-offset-1">
+            <div class="form-group">
+                <textarea name="page_text" class="form-control" placeholder="Post contents..." rows="20">{{page_text}}</textarea>
+            </div>
+        </div>
+    </div>
+
+    <input type="hidden" name="is_dos_nl" value="{{is_dos_nl}}" />
+
+    <div class="row">
+        <div class="col-md-8 col-md-offset-1">
+            <button type="submit" formtarget="_blank" name="do_preview" class="btn btn-info">Preview</button>
+            <a class="btn btn-danger" href="{{url_cancel}}">Cancel</a>
+        </div>
+        <div class="col-md-2">
+            <div class="btn-group">
+                <button type="button" name="do_save" class="btn btn-primary">Save</button>
+                <button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                    <span class="caret"></span>
+                    <span class="sr-only">Toggle Dropdown</span>
+                </button>
+                <ul class="dropdown-menu">
+                    <li><button type="submit" name="do_save_and_commit" class="btn btn-link">Save and Commit</button></li>
+                </ul>
+            </div>
+        </div>
+    </div>
+</div>
+</form>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/install.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,8 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="container">
+Please install this shit.
+</div>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/default.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,14 @@
+{% set wrapper_classes = 'ft-nav-container' %}
+
+{% extends 'layouts/master.html' %}
+
+{% block after_content %}
+<nav class="ft-nav">
+    <div class="ft-nav-title">
+        <img src="/static/img/foodtruck.png" alt="Food Truck" />
+        <div class="ft-nav-brand">FoodTruck</div>
+    </div>
+    {% include 'layouts/menu.html' %}
+</nav>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/master.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,38 @@
+<!doctype html>
+<html lang="">
+    <head>
+        <meta charset="utf-8"/>
+        <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
+        <title>{%if title%}{{title}} &ndash; {%endif%}FoodTruck</title>
+        <meta name="description" content="A PieCrust management dashboard"/>
+        <meta name="viewport" content="width=device-width, initial-scale=1"/>
+        <link rel="apple-touch-icon" href="apple-touch-icon.png"/>
+        <link rel="stylesheet" href="/static/css/foodtruck.min.css"/>
+        <link href='https://fonts.googleapis.com/css?family=Lobster' rel='stylesheet' type='text/css'/>
+    </head>
+    <body>
+        <div id="ft-wrapper" class="{{wrapper_classes}}">
+            <header>
+                {% if title %}<h1 class="title">{{title}}</h1>{% endif %}
+                {% block sub_header %}{% endblock %}
+            </header>
+            {% block before_content %}{% endblock %}
+            <section>
+                {% block content %}{% endblock %}
+            </section>
+            {% block after_content %}{% endblock %}
+            <div id="ft-bakelog"></div>
+            <footer>
+                <p>Prepared by <a href="http://bolt80.com">BOLT80</a>.</p>
+                <p>Much <span class="icon ion-heart"></span> to
+                <a href="http://python.org">Python</a>,
+                <a href="http://flask.pocoo.org">Flask</a>,
+                <a href="http://getbootstrap.com">Bootstrap</a>,
+                <a href="http://ionicons.com/">Ionicons</a>,
+                and many more.</p>
+            </footer>
+        </div>
+        <script src="/static/js/foodtruck.min.js"></script>
+    </body>
+</html>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/layouts/menu.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,17 @@
+<ul>
+    {%-for e in menu.entries%}
+        <li><a href="{{e.url}}" class="{%if e.active%}ft-nav-active{%elif e.entries and not e.open%}ft-nav-collapsed{%endif%}">
+        {%-if e.icon%}<span class="icon ion-{{e.icon}}"></span> {%endif%}{{e.title}}</a>
+        {%-if e.entries%}
+        <ul>
+            {%-for e2 in e.entries%}
+            <li><a href="{{e2.url}}" class="{%if e2.active%}ft-nav-active{%endif%}">{{e2.title}}</a></li>
+            {%endfor%}
+        </ul>
+        {%endif-%}
+    </li>
+    {%endfor%}
+</ul>
+{%if menu.user.is_authenticated%}
+<p class="ft-nav-auth">Logged in as {{menu.user.id}}. <a href="{{menu.url_logout}}">Logout</a>.</p>
+{%endif%}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/list_source.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,38 @@
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="container">
+    <div class="row">
+        <div class="col-xs-12">
+            {% for p in pages %}
+            <div><a href="{{p.url}}">{{p.title}}</a></div>
+            {% endfor %}
+        </div>
+
+        {% if pagination.prev_page or pagination.next_page %}
+        <div class="col-sm-6 col-sm-offset-3">
+            <div class="ft-pagination">
+                {% if pagination.prev_page %}
+                <a href="{{pagination.prev_page}}"><span class="icon ion-chevron-left"></span> prev</a>
+                {% else %}
+                <span class="icon ion-chevron-left"></span> prev
+                {% endif %}
+
+                {% for p in pagination.nums %}
+                {% if p.url %}<a href="{{p.url}}" class="ft-pagination-a">{{p.num}}</a>{% else %}<span class="ft-pagination-a">{{p.num}}</span>{% endif %}
+                {% endfor %}
+
+                {% if pagination.next_page %}
+                <a href="{{pagination.next_page}}">next <span class="icon ion-chevron-right"></span></a>
+                {% else %}
+                next <span class="icon ion-chevron-right"></span>
+                {% endif %}
+            </div>
+        </div>
+        {% endif %}
+    </div>
+</div>
+
+{% endblock %}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/login.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,35 @@
+{% set wrapper_classes = 'container' %}
+
+{% extends 'layouts/master.html' %}
+
+{% block content %}
+<div class="row">
+    <div class="col-md-6 col-md-offset-3">
+        <div class="ft-login">
+            <p class="login-box-msg">{{message|default('You know the drill.')}}</p>
+
+            <form action="{{login_postback}}" method="post">
+                <div class="form-group has-feedback">
+                    <input type="text" name="username" class="form-control" placeholder="Username">
+                    <span class="form-control-feedback"><i class="icon ion-log-in"></i></span>
+                </div>
+                <div class="form-group has-feedback">
+                    <input type="password" name="password" class="form-control" placeholder="Password">
+                    <span class="form-control-feedback"><i class="icon ion-locked"></i></span>
+                </div>
+                <div class="row">
+                    <div class="col-xs-8">
+                        <div class="checkbox">
+                            <label><input type="checkbox"> Remember Me</input></label>
+                        </div>
+                    </div>
+                    <div class="col-xs-4">
+                        <button type="submit" class="btn btn-primary btn-block">Log In</button>
+                    </div>
+                </div>
+            </form>
+        </div>
+    </div>
+</div>
+{% endblock %}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/templates/settings.html	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,38 @@
+{% set title = 'Settings' %}
+
+{% extends 'layouts/default.html' %}
+
+{% block content %}
+<div class="container">
+{% for sec in sections %}
+<form action="{{url_settings}}" method="POST">
+    <h2>{{sec.title}}</h2>
+    <input type="hidden" name="_section" value="{{sec.name}}" />
+
+    {% for op in sec.options %}
+    <div class="form-group">
+        {% if op.type == 'checkbox' %}
+        <div class="checkbox">
+            <label><input type="checkbox" name="{{op.name}}" value="true" {%if op.value == 'true'%}checked="true"{%endif%} />{{op.title}}</label>
+        </div>
+        {% else %}
+        <div class="input-group">
+            <span class="input-group-addon" id="ft-setting-{{op.name}}">{{op.title}}</span>
+            <input type="{{op.type}}" class="form-control" aria-describedby="ft-setting-{{op.name}}" name="{{op.name}}" value="{{op.value}}" />
+        </div>
+        {% endif %}
+    </div>
+    {% endfor %}
+
+    <button type="submit" name="_do_save" class="btn btn-success">Save Changes</button>
+</form>
+{% endfor %}
+<form action="{{url_reload_settings}}" method="POST">
+    <h2>Reload Settings</h2>
+    <p>This will reload settings from the configuration file.</p>
+    <button type="submit" name="_do_reload" class="btn btn-info">Reload Settings</button>
+</form>
+</div>
+{% endblock %}
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/__init__.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,28 @@
+from flask import render_template
+from flask.views import View
+from .menu import get_menu_context
+
+
+class FoodTruckView(View):
+    template_name = 'index.html'
+    requires_menu = True
+
+    def render_template(self, context):
+        if self.requires_menu:
+            context = with_menu_context()
+        return render_template(self.template_name, **context)
+
+    def get_context(self):
+        return None
+
+    def dispatch_request(self):
+        ctx = self.get_context()
+        return render_template(ctx)
+
+
+def with_menu_context(context=None):
+    if context is None:
+        context = {}
+    context['menu'] = get_menu_context()
+    return context
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/baking.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,116 @@
+import os
+import os.path
+import time
+import signal
+import logging
+from werkzeug.wrappers import Response
+from flask import g, redirect
+from flask.ext.login import login_required
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+server_shutdown = False
+
+
+def _shutdown_server_and_raise_sigint():
+    if not app.debug or os.environ.get('WERKZEUG_RUN_MAIN') == 'true':
+        # This is needed when hitting CTRL+C to shutdown the Werkzeug server,
+        # otherwise SSE generators will keep it alive.
+        logger.debug("Shutting down SSE generators...")
+        global server_shutdown
+        server_shutdown = True
+    raise KeyboardInterrupt()
+
+
+# Make sure CTRL+C works correctly.
+signal.signal(signal.SIGINT,
+              lambda *args: _shutdown_server_and_raise_sigint())
+
+
+class _BakeLogReader(object):
+    _bake_max_time = 10 * 60  # Don't bother about bakes older than 10mins.
+    _poll_interval = 2        # Check the PID file every 2 seconds.
+    _ping_interval = 30       # Send a ping message every 30 seconds.
+
+    def __init__(self, pid_path, log_path):
+        self.pid_path = pid_path
+        self.log_path = log_path
+        self._bake_pid_mtime = 0
+        self._last_seek = 0
+        self._last_ping_time = 0
+
+    def run(self):
+        logger.debug("Opening bake log...")
+
+        try:
+            while not server_shutdown:
+                # PING!
+                interval = time.time() - self._last_ping_time
+                if interval > self._ping_interval:
+                    logger.debug("Sending ping...")
+                    self._last_ping_time = time.time()
+                    yield bytes("event: ping\ndata: 1\n\n", 'utf8')
+
+                # Check pid file.
+                prev_mtime = self._bake_pid_mtime
+                try:
+                    self._bake_pid_mtime = os.path.getmtime(self.pid_path)
+                    if time.time() - self._bake_pid_mtime > \
+                            self._bake_max_time:
+                        self._bake_pid_mtime = 0
+                except OSError:
+                    self._bake_pid_mtime = 0
+
+                # Send data.
+                new_data = None
+                if self._bake_pid_mtime > 0 or prev_mtime > 0:
+                    if self._last_seek == 0:
+                        outstr = 'event: message\ndata: Bake started.\n\n'
+                        yield bytes(outstr, 'utf8')
+
+                    try:
+                        with open(self.log_path, 'r', encoding='utf8') as fp:
+                            fp.seek(self._last_seek)
+                            new_data = fp.read()
+                            self._last_seek = fp.tell()
+                    except OSError:
+                        pass
+                if self._bake_pid_mtime == 0:
+                    self._last_seek = 0
+
+                if new_data:
+                    logger.debug("SSE: %s" % outstr)
+                    for line in new_data.split('\n'):
+                        outstr = 'event: message\ndata: %s\n\n' % line
+                        yield bytes(outstr, 'utf8')
+
+                time.sleep(self._poll_interval)
+
+        except GeneratorExit:
+            pass
+
+        logger.debug("Closing bake log...")
+
+
+@app.route('/bake', methods=['POST'])
+@login_required
+def bake_site():
+    site = g.sites.get()
+    site.bake()
+    return redirect('/')
+
+
+@app.route('/bakelog')
+@login_required
+def stream_bake_log():
+    site = g.sites.get()
+    pid_path = os.path.join(site.root_dir, 'foodtruck_bake.pid')
+    log_path = os.path.join(site.root_dir, 'foodtruck_bake.log')
+    rdr = _BakeLogReader(pid_path, log_path)
+
+    response = Response(rdr.run(), mimetype='text/event-stream')
+    response.headers['Cache-Control'] = 'no-cache'
+    return response
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/create.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,73 @@
+import os.path
+import logging
+from flask import (
+        g, request, abort, render_template, url_for, redirect, flash)
+from flask.ext.login import login_required
+from piecrust.sources.interfaces import IInteractiveSource
+from piecrust.sources.base import MODE_CREATING
+from piecrust.routing import create_route_metadata
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/write/<source_name>', methods=['GET', 'POST'])
+@login_required
+def write_page(source_name):
+    site = g.sites.get().piecrust_app
+    source = site.getSource(source_name)
+    if source is None:
+        abort(400)
+    if not isinstance(source, IInteractiveSource):
+        abort(400)
+
+    if request.method == 'POST':
+        if 'do_save' in request.form:
+            metadata = dict(request.form.items())
+            form_keys = list(metadata.keys())
+            for k in filter(lambda k: not k.startswith('meta-'), form_keys):
+                del metadata[k]
+
+            fac = source.findPageFactory(metadata, MODE_CREATING)
+            if fac is None:
+                raise Exception("Can't find page for %s" % metadata)
+                abort(400)
+
+            logger.debug("Creating page: %s" % fac.path)
+            with open(fac.path, 'w', encoding='utf8') as fp:
+                fp.write(request.form['page_text'])
+            flash("%s was created." % os.path.relpath(fac.path, site.root_dir))
+
+            route = site.getRoute(source.name, fac.metadata,
+                                  skip_taxonomies=True)
+            dummy = object()
+            dummy.source_metadata = fac.metadata
+            dummy.getRouteMetadata = lambda: {}
+            route_metadata = create_route_metadata(dummy)
+            uri = route.getUri(route_metadata)
+
+            return redirect(url_for('edit_page', slug=uri))
+
+        abort(400)
+
+    return _write_page_form(source)
+
+
+def _write_page_form(source):
+    data = {}
+    data['is_new_page'] = True
+    data['source_name'] = source.name
+    data['url_postback'] = url_for('write_page', source_name=source.name)
+    data['fields'] = []
+    for f in source.getInteractiveFields():
+        data['fields'].append({
+            'name': f.name,
+            'display_name': f.name,
+            'type': f.field_type,
+            'value': f.default_value})
+
+    with_menu_context(data)
+    return render_template('create_page.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/edit.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,74 @@
+import os.path
+import logging
+from flask import (
+        g, request, abort, render_template, url_for, flash)
+from flask.ext.login import login_required
+from piecrust.rendering import (
+        PageRenderingContext, render_page)
+from piecrust.serving.util import get_requested_page
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/edit/', defaults={'slug': ''}, methods=['GET', 'POST'])
+@app.route('/edit/<path:slug>', methods=['GET', 'POST'])
+@login_required
+def edit_page(slug):
+    site = g.sites.get()
+    site_app = site.piecrust_app
+    rp = get_requested_page(site_app,
+                            '/site/%s/%s' % (g.sites.current_site, slug))
+    page = rp.qualified_page
+    if page is None:
+        abort(404)
+
+    if request.method == 'POST':
+        page_text = request.form['page_text']
+        if request.form['is_dos_nl'] == '0':
+            page_text = page_text.replace('\r\n', '\n')
+
+        if 'do_preview' in request.form or 'do_save' in request.form or \
+                'do_save_and_commit' in request.form:
+            logger.debug("Writing page: %s" % page.path)
+            with open(page.path, 'w', encoding='utf8') as fp:
+                fp.write(page_text)
+            flash("%s was saved." % os.path.relpath(
+                    page.path, site_app.root_dir))
+
+        if 'do_save_and_commit' in request.form:
+            author = g.config.get('scm/username')
+            message = "Edit %s" % os.path.relpath(
+                    page.path, site_app.root_dir)
+            site.scm.commit([page.path], author, message)
+
+        if 'do_preview' in request.form:
+            return _preview_page(page)
+
+        if 'do_save' in request.form or 'do_save_and_commit' in request.form:
+            return _edit_page_form(page)
+
+        abort(400)
+
+    return _edit_page_form(page)
+
+
+def _preview_page(page):
+    render_ctx = PageRenderingContext(page, force_render=True)
+    rp = render_page(render_ctx)
+    return rp.content
+
+
+def _edit_page_form(page):
+    data = {}
+    data['is_new_page'] = False
+    data['url_cancel'] = url_for('list_source', source_name=page.source.name)
+    with open(page.path, 'r', encoding='utf8') as fp:
+        data['page_text'] = fp.read()
+    data['is_dos_nl'] = "1" if '\r\n' in data['page_text'] else "0"
+
+    with_menu_context(data)
+    return render_template('edit_page.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/main.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,155 @@
+import os
+import os.path
+import logging
+from flask import (
+        g, request,
+        render_template, url_for, redirect, make_response)
+from flask.ext.login import login_user, logout_user, login_required
+from piecrust.configuration import parse_config_header
+from piecrust.rendering import QualifiedPage
+from piecrust.uriutil import split_uri
+from ..views import with_menu_context
+from ..web import app, load_user, after_this_request
+
+
+logger = logging.getLogger(__name__)
+
+
+@app.route('/')
+@login_required
+def index():
+    data = {}
+    site_name = request.cookies.get('foodtruck_site_name')
+    site = g.sites.get(site_name)
+    assert site is not None
+
+    fs_endpoints = {}
+    data['sources'] = []
+    for source in site.piecrust_app.sources:
+        if source.is_theme_source:
+            continue
+        facs = source.getPageFactories()
+        src_data = {
+                'name': source.name,
+                'list_url': url_for('list_source', source_name=source.name),
+                'page_count': len(facs)}
+        data['sources'].append(src_data)
+
+        fe = getattr(source, 'fs_endpoint', None)
+        if fe:
+            fs_endpoints[fe] = source
+
+    st = site.scm.getStatus()
+    data['new_pages'] = []
+    for p in st.new_files:
+        pd = _getWipData(p, site, fs_endpoints)
+        if pd:
+            data['new_pages'].append(pd)
+    data['edited_pages'] = []
+    for p in st.edited_files:
+        pd = _getWipData(p, site, fs_endpoints)
+        if pd:
+            data['edited_pages'].append(pd)
+
+    data['site_name'] = site.name
+    data['url_bake'] = url_for('bake_site')
+    data['url_preview'] = url_for('preview_site_root', sitename=site.name)
+
+    data['sites'] = []
+    for k, v in g.config.get('sites').items():
+        data['sites'].append({
+            'name': k,
+            'display_name': v.get('name', k),
+            'url': url_for('index', site_name=site_name)
+            })
+    data['url_switch'] = url_for('switch_site')
+
+    with_menu_context(data)
+    return render_template('dashboard.html', **data)
+
+
+def _getWipData(path, site, fs_endpoints):
+    source = None
+    for endpoint, s in fs_endpoints.items():
+        if path.startswith(endpoint):
+            source = s
+            break
+    if source is None:
+        return None
+
+    fac = source.buildPageFactory(os.path.join(site.root_dir, path))
+    route = site.piecrust_app.getRoute(
+            source.name, fac.metadata, skip_taxonomies=True)
+    if not route:
+        return None
+
+    qp = QualifiedPage(fac.buildPage(), route, fac.metadata)
+    uri = qp.getUri()
+    _, slug = split_uri(site.piecrust_app, uri)
+
+    with open(fac.path, 'r', encoding='utf8') as fp:
+        raw_text = fp.read()
+
+    preferred_length = 100
+    max_length = 150
+    header, offset = parse_config_header(raw_text)
+    extract = raw_text[offset:offset + preferred_length]
+    if len(raw_text) > offset + preferred_length:
+        for i in range(offset + preferred_length,
+                       min(offset + max_length, len(raw_text))):
+            c = raw_text[i]
+            if c not in [' ', '\t', '\r', '\n']:
+                extract += c
+            else:
+                extract += '...'
+                break
+
+    return {
+            'title': qp.config.get('title'),
+            'slug': slug,
+            'url': url_for('edit_page', slug=slug),
+            'text': extract
+            }
+
+
+@login_required
+@app.route('/switch_site', methods=['POST'])
+def switch_site():
+    site_name = request.form.get('site_name')
+    if not site_name:
+        return redirect(url_for('index'))
+
+    @after_this_request
+    def _save_site(resp):
+        resp.set_cookie('foodtruck_site_name', site_name)
+
+    return redirect(url_for('index'))
+
+
+@app.route('/login', methods=['GET', 'POST'])
+def login():
+    data = {}
+
+    if request.method == 'POST':
+        username = request.form.get('username')
+        password = request.form.get('password')
+        remember = request.form.get('remember')
+
+        user = load_user(username)
+        if user is not None and app.bcrypt:
+            if app.bcrypt.check_password_hash(user.password, password):
+                login_user(user, remember=bool(remember))
+                return redirect(url_for('index'))
+        data['message'] = (
+                "User '%s' doesn't exist or password is incorrect." %
+                username)
+
+    return render_template('login.html', **data)
+
+
+@app.route('/logout')
+@login_required
+def logout():
+    logout_user()
+    return redirect(url_for('index'))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/menu.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,63 @@
+from flask import g, request, url_for
+from flask.ext.login import current_user
+
+
+def get_menu_context():
+    entries = []
+    entries.append({
+        'url': '/',
+        'title': "Dashboard",
+        'icon': 'speedometer'})
+
+    site = g.sites.get().piecrust_app
+    for s in site.sources:
+        if s.is_theme_source:
+            continue
+
+        source_icon = s.config.get('foodtruck_icon/document')
+        if s.name == 'pages':
+            source_icon = 'document-text'
+        elif 'blog' in s.name:
+            source_icon = 'filing'
+
+        url_write = url_for('write_page', source_name=s.name)
+        url_listall = url_for('list_source', source_name=s.name)
+
+        ctx = {
+                'url': url_listall,
+                'title': s.name,
+                'icon': source_icon,
+                'entries': [
+                    {'url': url_listall, 'title': "List All"},
+                    {'url': url_write, 'title': "Write New"}
+                    ]
+                }
+        entries.append(ctx)
+
+    entries.append({
+        'url': url_for('settings'),
+        'title': "Settings",
+        'icon': 'gear-b'})
+
+    for e in entries:
+        needs_more_break = False
+        if 'entries' in e:
+            for e2 in e['entries']:
+                if e2['url'] == request.path:
+                    e['open'] = True
+                    e2['active'] = True
+                    needs_more_break = True
+                    break
+        if needs_more_break:
+            break
+
+        if e['url'] == request.path:
+            e['active'] = True
+            break
+
+    data = {'entries': entries,
+            'user': current_user,
+            'url_logout': url_for('logout')}
+    return data
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/preview.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,25 @@
+import os.path
+from flask import g, make_response
+from flask.ext.login import login_required
+from piecrust import CACHE_DIR
+from piecrust.serving.server import Server
+from ..web import app
+
+
+@app.route('/site/<sitename>/')
+@login_required
+def preview_site_root(sitename):
+    return preview_site(sitename, '/')
+
+
+@app.route('/site/<sitename>/<path:url>')
+@login_required
+def preview_site(sitename, url):
+    root_dir = g.sites.get_root_dir(sitename)
+    sub_cache_dir = os.path.join(root_dir, CACHE_DIR, 'foodtruck')
+    server = Server(root_dir, sub_cache_dir=sub_cache_dir,
+                    root_url='/site/%s/' % sitename,
+                    debug=app.debug)
+    return make_response(server._run_request)
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/settings.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,98 @@
+import logging
+import bcrypt
+from flask import g, request, abort, render_template, redirect, url_for
+from flask.ext.login import login_required
+from ..views import with_menu_context
+from ..web import app
+
+
+logger = logging.getLogger(__name__)
+
+
+def _hash_password(v):
+    binpw = v.encode('utf8')
+    v = bcrypt.hashpw(binpw, bcrypt.gensalt()).decode('utf8')
+    return v
+
+
+config_meta = {
+        'scm.title': 'Source Control',
+        'triggers.bake.is_shell.title': 'Use Shell'
+        }
+
+config_defaults = {
+        'triggers': {
+            'bake.is_shell': 'false'
+            }
+        }
+
+config_coercer = {
+        'security.password': _hash_password
+        }
+
+
+@app.route('/settings', methods=['GET', 'POST'])
+@login_required
+def settings():
+    if request.method == 'POST':
+        if '_do_save' not in request.form:
+            abort(400)
+        if not g.config.has(request.form['_section']):
+            abort(400)
+
+        secname = request.form['_section']
+
+        defaults = config_defaults.get(secname)
+        if defaults:
+            for n, v in defaults.items():
+                g.config.set('%s/%s' % (secname, n), v)
+
+        for n, v in request.form.items():
+            if n in ['_section', '_do_save']:
+                continue
+
+            coercer = config_coercer.get('%s.%s' % (secname, n))
+            if coercer:
+                v = coercer(v)
+
+            logger.debug("Setting %s.%s to %s" % (secname, n, v))
+            g.config.set('%s/%s' % (secname, n), v)
+
+        g.config.save()
+
+    data = {'sections': []}
+    for secname, sec in g.config.items():
+        if secname in ['DEFAULT', 'foodtruck', 'sites']:
+            continue
+        secdata = {}
+        secdata['name'] = secname
+        secdata['title'] = config_meta.get(
+                '%s.title' % secname, secname.title())
+        secdata['options'] = []
+        for n, v in sec.items():
+            opdata = {}
+            opdata['name'] = n
+            opdata['title'] = config_meta.get(
+                    '%s.%s.title' % (secname, n), n.title())
+            opdata['value'] = v
+            opdata['type'] = 'text'
+            if v in ['true', 'false']:
+                opdata['type'] = 'checkbox'
+            if secname == 'security' and n == 'password':
+                opdata['type'] = 'password'
+            secdata['options'].append(opdata)
+        data['sections'].append(secdata)
+
+    data['url_settings'] = url_for('settings')
+    data['url_reload_settings'] = url_for('reload_settings')
+    with_menu_context(data)
+    return render_template('settings.html', **data)
+
+
+@app.route('/reload', methods=['POST'])
+@login_required
+def reload_settings():
+    from ..web import reload_foodtruck
+    reload_foodtruck()
+    return redirect(url_for('settings'))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/views/sources.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,55 @@
+from flask import g, abort, render_template, url_for
+from flask.ext.login import login_required
+from piecrust.data.paginator import Paginator
+from ..views import with_menu_context
+from ..web import app
+
+
+@app.route('/list/<source_name>/', defaults={'page_num': 1})
+@app.route('/list/<source_name>/<int:page_num>')
+@login_required
+def list_source(source_name, page_num):
+    site = g.sites.get().piecrust_app
+    source = site.getSource(source_name)
+    if source is None:
+        abort(400)
+
+    i = 0
+    data = {'title': "List %s" % source_name}
+    data['pages'] = []
+    pgn = Paginator(None, source, page_num=page_num, items_per_page=20)
+    for p in pgn.items:
+        page_data = {
+                'title': p['title'],
+                'slug': p['slug'],
+                'source': source_name,
+                'url': url_for('edit_page', slug=p['slug'])}
+        data['pages'].append(page_data)
+
+    prev_page_url = None
+    if pgn.prev_page_number:
+        prev_page_url = url_for(
+                'list_source', source_name=source_name,
+                page_num=pgn.prev_page_number)
+    next_page_url = None
+    if pgn.next_page_number:
+        next_page_url = url_for(
+                'list_source', source_name=source_name,
+                page_num=pgn.next_page_number)
+
+    page_urls = []
+    for i in pgn.all_page_numbers(7):
+        url = None
+        if i != page_num:
+            url = url_for('list_source', source_name=source_name, page_num=i)
+        page_urls.append({'num': i, 'url': url})
+
+    data['pagination'] = {
+            'prev_page': prev_page_url,
+            'next_page': next_page_url,
+            'nums': page_urls
+            }
+
+    with_menu_context(data)
+    return render_template('list_source.html', **data)
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruck/web.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,121 @@
+import logging
+from flask import Flask, g, request, render_template
+from .config import (
+        FoodTruckConfigNotFoundError, get_foodtruck_config)
+from .sites import FoodTruckSites
+
+
+logger = logging.getLogger(__name__)
+
+app = Flask(__name__)
+
+c = get_foodtruck_config()
+app.secret_key = c.get('foodtruck/secret_key')
+del c
+
+
+def after_this_request(f):
+    if not hasattr(g, 'after_request_callbacks'):
+        g.after_request_callbacks = []
+    g.after_request_callbacks.append(f)
+    return f
+
+
+class LazySomething(object):
+    def __init__(self, factory):
+        self._factory = factory
+        self._something = None
+
+    def __getattr__(self, name):
+        if self._something is not None:
+            return getattr(self._something, name)
+
+        self._something = self._factory()
+        return getattr(self._something, name)
+
+
+@app.before_request
+def _setup_foodtruck_globals():
+    def _get_sites():
+        s = FoodTruckSites(g.config,
+                           request.cookies.get('foodtruck_site_name'))
+        return s
+    g.config = LazySomething(get_foodtruck_config)
+    g.sites = LazySomething(_get_sites)
+
+
+@app.after_request
+def _call_after_request_callbacks(response):
+    for callback in getattr(g, 'after_request_callbacks', ()):
+        callback(response)
+    return response
+
+
+@app.errorhandler(FoodTruckConfigNotFoundError)
+def _on_config_missing(ex):
+    return render_template('install.html')
+
+
+@app.errorhandler
+def _on_error(ex):
+    logging.exception(ex)
+
+
+from flask.ext.login import LoginManager, UserMixin
+
+
+class User(UserMixin):
+    def __init__(self, uid, pwd):
+        self.id = uid
+        self.password = pwd
+
+
+def load_user(user_id):
+    admin_id = g.config.get('security/username')
+    if admin_id == user_id:
+        admin_pwd = g.config.get('security/password')
+        return User(admin_id, admin_pwd)
+    return None
+
+
+login_manager = LoginManager()
+login_manager.init_app(app)
+login_manager.login_view = 'login'
+login_manager.user_loader(load_user)
+
+
+try:
+    from flask.ext.bcrypt import Bcrypt
+except ImportError:
+    logging.warning("Bcrypt not available... falling back to SHA512.")
+    logging.warning("Run `pip install Flask-Bcrypt` for more secure "
+                    "password hashing.")
+
+    import hashlib
+
+    def generate_password_hash(password):
+        return hashlib.sha512(password.encode('utf8')).hexdigest()
+
+    def check_password_hash(reference, check):
+        check_hash = hashlib.sha512(check.encode('utf8')).hexdigest()
+        return check_hash == reference
+
+    class SHA512Fallback(object):
+        def __init__(self, app=None):
+            self.generate_password_hash = generate_password_hash
+            self.check_password_hash = check_password_hash
+
+    Bcrypt = SHA512Fallback
+
+app.bcrypt = Bcrypt(app)
+
+
+import foodtruck.views.baking  # NOQA
+import foodtruck.views.create  # NOQA
+import foodtruck.views.edit  # NOQA
+import foodtruck.views.main  # NOQA
+import foodtruck.views.menu  # NOQA
+import foodtruck.views.preview  # NOQA
+import foodtruck.views.settings  # NOQA
+import foodtruck.views.sources  # NOQA
+
Binary file foodtruckui/assets/img/foodtruck.png has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/assets/js/foodtruck.js	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,43 @@
+
+$(document).ready(function() {
+    $('.ft-nav-collapsed + ul').hide();
+});
+
+if (!!window.EventSource) {
+    var source = new EventSource('/bakelog');
+
+    source.onerror = function(e) {
+        console.log("Error with SSE, closing.", e);
+        source.close();
+    };
+    source.addEventListener('message', function(e) {
+        var msgEl = $('<div></div>');
+
+        var removeMsgEl = function() {
+            msgEl.remove();
+            var bakelogEl = $('#ft-bakelog');
+            if (bakelogEl.children().length == 0) {
+                bakelogEl.hide();
+            }
+        };
+
+        msgEl.addClass('alert-dismissible');
+        msgEl.attr('role', 'alert');
+        msgEl.append('<button type="button" class="close" data-dismiss="alert" aria-label="close">' +
+                     '<span aria-hidden="true">&times;</span></button>');
+        msgEl.append('<p>' + e.data + '</p>');
+        var timeoutId = window.setTimeout(function() {
+            msgEl.fadeOut(400, removeMsgEl);
+        }, 4000);
+        msgEl.mouseenter(function() {
+            window.clearTimeout(timeoutId);
+        });
+        $('button', msgEl).click(removeMsgEl);
+
+        var logEl = $('#ft-bakelog');
+        logEl.append(msgEl);
+        logEl.show();
+    });
+}
+
+
Binary file foodtruckui/assets/raw/foodtruck.psd has changed
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/assets/sass/foodtruck.scss	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,42 @@
+
+// Overrides
+$icon-font-path: '../fonts/';
+
+// Core variables and mixins
+@import "bootstrap/variables";
+@import "bootstrap/mixins";
+
+// Reset and dependencies
+@import "bootstrap/normalize";
+@import "bootstrap/print";
+@import "bootstrap/glyphicons";
+
+// Core CSS
+@import "bootstrap/scaffolding";
+@import "bootstrap/type";
+@import "bootstrap/code";
+@import "bootstrap/grid";
+@import "bootstrap/tables";
+@import "bootstrap/forms";
+@import "bootstrap/buttons";
+
+// Components
+@import "bootstrap/alerts";
+@import "bootstrap/button-groups";
+@import "bootstrap/close";
+@import "bootstrap/component-animations";
+@import "bootstrap/dropdowns";
+@import "bootstrap/input-groups";
+
+// Utility classes
+@import "bootstrap/utilities";
+@import "bootstrap/responsive-utilities";
+
+// Ionicons
+@import "ionicons";
+
+// Foodtruck
+@import "foodtruck/base";
+@import "foodtruck/sidebar";
+@import "foodtruck/editing";
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/assets/sass/foodtruck/_base.scss	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,62 @@
+
+$ft-color-gray-darkest:  #1A2226;
+$ft-color-gray-darker:   #1E282C;
+$ft-color-gray-dark:     #222D32;
+$ft-color-gray:          #2C3B41;
+$ft-color-gray-light:    #8AA4AF;
+$ft-color-gray-lighter:  #B8C7CE;
+$ft-color-white:         #FFF;
+$ft-color-black:         #000;
+$ft-color-red:           #D33939;
+$ft-color-blue:          #3C8DBC;
+$ft-color-yellow:        #C9C836;
+
+
+header h1, header.title {
+    text-align: center;
+}
+
+footer {
+    text-align: center;
+    font-size: 0.8em;
+    letter-spacing: -0.02em;
+    color: #777;
+    margin: 4em 2em 2em 2em;
+}
+
+h1, h2, h3, h4, h5, h6 {
+    margin-bottom: 1em;
+}
+
+.ft-login {
+    padding: 1em;
+    margin: 2em 0;
+    box-shadow: 0 5px 10px #ddd;
+    border: 1px solid #dcdcdc;
+}
+
+.ft-pagination {
+    text-align: center;
+    color: $ft-color-gray;
+    font-size: 2em;
+    margin: 1em 0;
+}
+.ft-pagination-a {
+    padding: 0 0.2em;
+}
+
+#ft-bakelog {
+    position: fixed;
+    right: 0;
+    bottom: 0;
+    margin: 0.5em;
+    padding: 1em;
+    width: 50%;
+    max-width: 30em;
+    color: $ft-color-gray;
+    background: $ft-color-gray-lighter;
+    border: 1px solid $ft-color-gray-light;
+    border-radius: 0.5em;
+    display: none;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/assets/sass/foodtruck/_editing.scss	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,13 @@
+//
+// Page editing
+// --------------------------------------------------
+
+.ft-write-form textarea {
+    @include resizable(vertical);
+
+    outline: none;
+    overflow: auto;
+    font-family: 'Courier', 'Courier New', sans-serif;
+    padding: 1em;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/assets/sass/foodtruck/_sidebar.scss	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,120 @@
+//
+// Sidebar navigation
+// --------------------------------------------------
+
+$ft-nav-width: 25rem;
+$ft-nav-margin: 2rem;
+
+// Layout
+.ft-nav-container {
+    padding: 2rem;
+    transition: padding-left 0.5s ease;
+}
+.ft-nav-container.ft-nav-enabled {
+    padding-left: $ft-nav-width + $ft-nav-margin;
+}
+
+.ft-nav {
+    z-index: 1000;
+    position: fixed;
+    height: 100%;
+    width: $ft-nav-width;
+    left: -$ft-nav-width;
+    top: 0;
+    bottom: 0;
+    overflow-y: auto;
+    transition: all 0.5s ease;
+}
+.ft-nav.ft-nav-enabled {
+    left: 0;
+}
+
+@media(min-width:768px) {
+    .ft-nav-container {
+        padding-left: $ft-nav-width + $ft-nav-margin;
+    }
+    .ft-nav {
+        left: 0;
+    }
+}
+
+// Style
+.ft-nav {
+    background: $ft-color-gray-darkest;
+    color: #fff;
+
+    span.icon {
+        font-size: 1.5em;
+        margin-right: 0.4em;
+    }
+}
+.ft-nav ul {
+    list-style: none;
+    margin: 0;
+    padding: 0;
+}
+.ft-nav ul li {
+    margin: 0;
+    padding: 0;
+}
+.ft-nav ul li a {
+    border-left: 5px solid transparent;
+    color: $ft-color-gray-light;
+    background: $ft-color-gray-dark;
+    padding: 1rem;
+    display: block;
+    letter-spacing: 0.05em;
+    text-transform: uppercase;
+
+    &:hover {
+        border-left: 5px solid $ft-color-blue;
+        color: $ft-color-white;
+        background: $ft-color-gray-darker;
+        text-decoration: none;
+    }
+}
+.ft-nav li>ul {
+}
+.ft-nav li>ul li a {
+    color: $ft-color-gray-lighter;
+    background: $ft-color-gray;
+    padding-left: 3em;
+    text-transform: none;
+
+    &:hover {
+        color: $ft-color-white;
+        background: $ft-color-gray;
+    }
+}
+.ft-nav ul li a.ft-nav-active {
+    border-left: 5px solid $ft-color-red;
+    color: $ft-color-white;
+    background: $ft-color-gray-darker;
+}
+.ft-nav li>ul li a.ft-nav-active {
+    color: $ft-color-white;
+    background: $ft-color-gray;
+}
+
+// Title/logo
+.ft-nav-title {
+    font-size: 2rem;
+    font-weight: bold;
+    text-align: center;
+    padding: 2rem 0;
+    margin: 0;
+}
+.ft-nav-brand {
+    font-family: 'Lobster', cursive;
+    font-size: 2em;
+    text-shadow: 2px 5px 0 $ft-color-gray;
+}
+
+// Footer
+.ft-nav-auth {
+    color: $ft-color-gray;
+    font-size: 0.8em;
+    text-align: center;
+    margin: 2em 0;
+}
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/bower.json	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,17 @@
+{
+  "name": "FoodTruck",
+  "description": "",
+  "authors": [
+    "Ludovic Chabant <ludovic@chabant.com>"
+  ],
+  "homepage": "http://bolt80.com/piecrust",
+  "ignore": [
+    "node_modules",
+    "bower_components"
+  ],
+  "dependencies": {
+    "bootstrap-sass": "~3.3.5",
+    "font-awesome": "fontawesome#~4.5.0",
+    "Ionicons": "ionicons#~2.0.1"
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/gulpfile.js	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,80 @@
+'use strict';
+
+var gulp = require('gulp'),
+    util = require('gulp-util'),
+    sass = require('gulp-sass'),
+    sourcemaps = require('gulp-sourcemaps'),
+    rename = require('gulp-rename'),
+    minify = require('gulp-minify-css'),
+    concat = require('gulp-concat'),
+    uglify = require('gulp-uglify');
+
+// Stylesheets
+gulp.task('sass', function() {
+    //util.log("Generating CSS");
+    return gulp.src('assets/sass/**/*.scss')
+        //.pipe(sourcemaps.init())
+        .pipe(sass({
+            errLogToConsole: true,
+            outputStyle: 'compressed',
+            includePaths: [
+                'bower_components/bootstrap-sass/assets/stylesheets',
+                'bower_components/Ionicons/scss']}))
+        //.pipe(sourcemaps.write())
+        .pipe(rename({suffix: '.min'}))
+        .pipe(minify())
+        .pipe(gulp.dest('../foodtruck/static/css'));
+});
+gulp.task('sass:watch', function() {
+    return gulp.watch('assets/sass/**/*.scss', ['sass']);
+});
+
+// Javascript
+gulp.task('js', function() {
+    return gulp.src([
+            'bower_components/jquery/dist/jquery.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/alert.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/button.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/collapse.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/dropdown.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/tooltip.js',
+            'bower_components/bootstrap-sass/assets/javascripts/bootstrap/transition.js',
+            'assets/js/**/*.js'
+            ])
+        .pipe(sourcemaps.init())
+        .pipe(concat('foodtruck.js'))
+        .pipe(sourcemaps.write())
+        .pipe(rename({suffix: '.min'}))
+        //.pipe(uglify())
+        .pipe(gulp.dest('../foodtruck/static/js'));
+});
+gulp.task('js:watch', function() {
+    return gulp.watch('assets/js/**/*.js', ['js']);
+});
+
+// Fonts/images
+gulp.task('fonts', function() {
+    return gulp.src([
+            'bower_components/bootstrap-sass/assets/fonts/bootstrap/*',
+            'bower_components/Ionicons/fonts/*'
+        ])
+        .pipe(gulp.dest('../foodtruck/static/fonts'));
+});
+
+gulp.task('images', function() {
+    return gulp.src([
+            'bower_components/bootstrap-sass/assets/images/*',
+            'assets/img/*'
+        ])
+        .pipe(gulp.dest('../foodtruck/static/img'));
+});
+
+// Launch tasks
+gulp.task('default', function() {
+    gulp.start(['sass', 'js', 'fonts', 'images']);
+});
+
+gulp.task('watch', function() {
+    gulp.start(['sass:watch', 'js:watch']);
+});
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/package.json	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,14 @@
+{
+  "name": "FoodTruck",
+  "version": "1.0.0",
+  "devDependencies": {
+    "gulp": "~3.9.0",
+    "gulp-sass": "~2.1.0",
+    "gulp-minify-css": "~1.2.1",
+    "gulp-uglify": "~1.4.2",
+    "gulp-util": "~3.0.7",
+    "gulp-sourcemaps": "~1.6.0",
+    "gulp-rename": "~1.2.2",
+    "gulp-concat": "~2.6.0"
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/foodtruckui/tests/test_config.py	Sat Jan 16 14:24:35 2016 -0800
@@ -0,0 +1,21 @@
+import os.path
+from foodtruck.config import FoodTruckConfig
+
+
+default_config = os.path.join(
+        os.path.dirname(__file__),
+        '..',
+        'foodtruck',
+        'foodtruck.cfg.defaults')
+
+
+def test_getcomplex_option():
+    cstr = '''[foo]
+    bar.name = My bar
+    bar.path = /path/to/bar
+    '''
+    c = FoodTruckConfig(None, None)
+    c.load_from_string(cstr)
+    expected = {'name': "My bar", 'path': '/path/to/bar'}
+    assert c.getcomplex('foo', 'bar') == expected
+
--- a/setup.py	Sun Jan 10 10:51:11 2016 -0800
+++ b/setup.py	Sat Jan 16 14:24:35 2016 -0800
@@ -200,6 +200,7 @@
             ],
         entry_points={'console_scripts': [
             'chef = piecrust.main:main',
+            'foodtruck = foodtruck.main:main'
             ]}
         )