Add Frontend

This commit is contained in:
2022-07-13 17:12:44 -07:00
parent c0aa590042
commit b43b254a47
10 changed files with 492 additions and 4 deletions

View File

@@ -9,4 +9,4 @@ from .socketio import socketio
app = create_app() app = create_app()
socketio.run(app) socketio.run(app, host='0.0.0.0')

84
sigl/domain/service.py Normal file
View File

@@ -0,0 +1,84 @@
"""Sigl Domain Services.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from typing import List, Optional, Union
from sqlalchemy.orm import Session
from sigl.exc import NotFoundError
from .models import ListEntry, ShoppingList
def lists_all(session: Session) -> List[ShoppingList]:
"""Return all Shopping Lists."""
return session.query(ShoppingList).all()
def list_by_id(session: Session, id: int) -> Optional[ShoppingList]:
"""Load a specific Shopping List."""
return session.query(ShoppingList).filter(ShoppingList.id == id).one_or_none()
def list_create(session: Session, name: str, *, notes=None) -> ShoppingList:
"""Create a new Shopping List."""
sList = ShoppingList(name=name, notes=notes)
session.add(sList)
session.commit()
return sList
def list_delete(session: Session, id: int):
"""Delete a Shopping List."""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
session.delete(sList)
session.commit()
def list_stores(session: Session, id: int) -> List[str]:
"""Get a list of all Stores for the List.
This helper returns a list of all Stores for which the Products in the
List have locations.
"""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
stores = set()
for e in sList.entries:
for loc in (e.product.locations or []):
stores.add(loc.store)
if '' in stores:
stores.remove('')
if None in stores:
stores.remove(None)
return list(stores)
def list_update(
session: Session,
id: int,
name: Union[str,None],
notes: Union[str,None],
) -> ShoppingList:
"""Update the Name and/or Notes of a Shopping List."""
sList = list_by_id(session, id)
if not sList:
raise NotFoundError(f'List {id} does not exist')
sList.name = name
sList.notes = notes
session.add(sList)
session.commit()
return sList

View File

@@ -33,3 +33,13 @@ class ConfigError(Error):
"""Class Constructor.""" """Class Constructor."""
super().__init__(*args) super().__init__(*args)
self.config_key = config_key self.config_key = config_key
class DomainError(Error):
"""Exception raised for domain logic errors."""
pass
class NotFoundError(Error):
"""Exception raised when an object cannot be found."""
pass

View File

@@ -61,7 +61,7 @@ def create_app(app_config=None, app_name=None):
""" """
app = Flask( app = Flask(
'sigl', 'sigl',
template_folder='templates' static_folder='../static',
) )
# Load Application Name and Version from pyproject.toml # Load Application Name and Version from pyproject.toml
@@ -134,8 +134,8 @@ def create_app(app_config=None, app_name=None):
init_socketio(app) init_socketio(app)
# Initialize Frontend # Initialize Frontend
from .frontend import init_frontend from .views import init_views
init_frontend(app) init_views(app)
# Startup Complete # Startup Complete
app.logger.info('{} startup complete'.format(app.config['APP_NAME'])) app.logger.info('{} startup complete'.format(app.config['APP_NAME']))

116
sigl/templates/base.html.j2 Normal file
View File

@@ -0,0 +1,116 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{% block title %}{% endblock %}</title>
<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
{% block head_scripts %}{% endblock %}
{% if config['ENV'] == 'production' %}
<!-- <link rel="stylesheet" href="{{ url_for('static', filename='css/sigl.css') }}"> -->
{% else %}
<script src="https://cdn.tailwindcss.com"></script>
{% endif %}
{% block head_styles %}{% endblock %}
</head>
<body class="h-full bg-gray-200">
<header>
<nav class="bg-gray-800 border-b border-gray-600">
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-12">
<div class="flex items-center">
<div class="flex-shrink-0">
<img class="h-8 w-8" src="https://tailwindui.com/img/logos/workflow-mark-indigo-500.svg" alt="Workflow">
</div>
<div>
<div class="ml-4 flex items-baseline space-x-4">
<a href="{{ url_for('lists.home') }}" class="text-white border-b border-white mx-2 py-1 text-sm font-medium" aria-current="page">Shopping Lists</a>
<a href="#" class="text-gray-300 hover:bg-gray-700 hover:text-white px-2 py-1 rounded-md text-sm font-medium">Products</a>
</div>
</div>
</div>
<div>
<div class="-mr-2 flex">
<!-- Mobile menu button -->
<button type="button" class="bg-gray-800 inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-white hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-gray-800 focus:ring-white" aria-expanded="false">
<span class="sr-only">App Settings</span>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</nav>
<section class="max-w-3xl mx-auto px-2 bg-white md:border-l md:border-r border-b border-gray-300">
{% block header %}{% endblock %}
</section>
</header>
<main class="max-w-3xl mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
{% block main %}{% endblock %}
</main>
{% block body_scripts %}{% endblock %}
<div
x-data="noticesHandler()"
class="fixed inset-0 flex flex-col items-end justify-start h-screen w-screen"
style="pointer-events:none"
@notice.document="add($event.detail)"
>
<template x-for="notice of notices" :key="notice.id">
<div
x-show="visible.includes(notice)"
x-transition:enter="transition ease-in duration-200"
x-transition:enter-start="transform opacity-0 translate-y-2"
x-transition:enter-end="transform opacity-100"
x-transition:leave="transition ease-out duration-500"
x-transition:leave-start="transform translate-x-0 opacity-100"
x-transition:leave-end="transform translate-x-full opacity-0"
@click="remove(notice.id)"
class="rounded max-w-[75%] mt-4 mr-6 px-1 py-1 flex items-center justify-center text-white shadow-lg font-bold text-sm cursor-pointer"
:class="{
'bg-green-500': notice.type === 'success',
'bg-blue-500': notice.type === 'info',
'bg-orange-500': notice.type === 'warning',
'bg-red-500': notice.type === 'error',
}"
style="pointer-events:all"
x-text="notice.text"
>
</div>
</template>
</div>
<script language="javascript">
document.addEventListener('alpine:init', function () {
Alpine.data('noticesHandler', () => ({
notices: [],
visible: [],
add(notice) {
notice.id = Date.now()
this.notices.push(notice)
this.fire(notice.id)
},
fire(id) {
this.visible.push(this.notices.find(notice => notice.id == id))
const timeShown = 2000 * this.visible.length
setTimeout(() => {
this.remove(id)
}, timeShown)
},
remove(id) {
const notice = this.visible.find(notice => notice.id == id)
const index = this.visible.indexOf(notice)
this.visible.splice(index, 1)
},
}));
});
document.addEventListener('alpine:initialized', function () {
{% for category, message in get_flashed_messages(with_categories=True) %}
document.dispatchEvent(new CustomEvent('notice', { detail: { type: {{ category|tojson }}, text: {{ message|tojson }} } }));
{% endfor %}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,29 @@
{% extends 'base.html.j2' %}
{% block title %}Create New List | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-1">
<div class="text-sm font-bold text-gray-800">Create New List</div>
<div class="flex justify-start items-start">
<a href="{{ url_for('lists.home') }}" class="px-2 py-1 text-xs text-white bg-gray-600 hover:bg-gray-700 border rounded flex justify-between items-center">
Cancel
</a>
</div>
</div>
{% endblock %}
{% block main %}
<form method="post">
<div class="py-2 px-4 flex flex-col">
<div class="flex flex-col pb-4">
<label for="name" class="py-1 text-xs text-gray-700 font-semibold">Shopping List Name:</label>
<input type="text" name="name" id="name" class="p-1 text-sm border border-gray-200 rounded" />
</div>
<div class="flex flex-col pb-4">
<label for="notes" class="py-1 text-xs text-gray-700 font-semibold">Notes:</label>
<textarea name="notes" id="notes" class="p-1 text-sm border border-gray-200 rounded"></textarea>
</div>
<div class="flex justify-end">
<button type="submit" class="px-2 py-1 border rounded text-sm text-white bg-blue-600 hover:bg-blue-700">Create</button>
</div>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,88 @@
{% extends 'base.html.j2' %}
{% block title %}{{ list.name }} | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg font-bold text-gray-800">{{ list.name }}</div>
<div class="flex justify-start items-start pr-1">
<a href="#" class="px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Add Item
</a>
<a href="#" class="ml-4 py-1 text-sm text-gray-800 hover:text-blue-700">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</a>
<button id="delete-list-btn" class="ml-3 py-1 text-sm text-gray-800 hover:text-red-700" onclick="deleteList()">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
{% endblock %}
{% block main %}
<div class="fixed bottom-0 flex max-w-3xl w-full mx-auto bg-white md:border-l md:border-r border-b md:rounded-b border-gray-300">
<div class="w-full flex items-center justify-between px-2 py-2 text-sm">
<label for="sorting" class="font-bold">Sort By: </label>
<select name="sorting" id="sorting" class="flex-grow ml-2 p-1 bg-white border rounded" onchange="changeSorting(event)">
<option value="none"{% if sortBy == 'none' %} selected="selected"{% endif %}>None</option>
<option value="category"{% if sortBy == 'category' %} selected="selected"{% endif %}>Category</option>
{% for store in stores %}
<option value="store:{{ store }}"{% if sortBy == 'store' and sortStore|lower == store|lower %} selected="selected"{% endif %}>Location ({{ store }})</option>
{% endfor %}
</select>
</div>
</div>
{% if list.entries|length == 0 %}
<div class="py-2 bg-white">
<div class="ml-4 text-sm">No Items</div>
</div>
{% else %}
{% if sortBy == 'none' %}
<ul>
{% for e in list.entries %}
<li>{{ e.product.name }}</li>
{% endfor %}
</ul>
{% elif sortBy == 'category' %}
{% for hdr, entries in groups.items() %}
<div class="text-sm text-gray-600 font-bold py-1 px-2">{{ hdr }}</div>
<ul>
{% for e in entries %}
<li>{{ e.entry.product.name }}</li>
{% endfor %}
</ul>
{% endfor %}
{% elif sortBy == 'store' %}
{% for hdr, entries in groups.items() %}
<div class="text-sm text-gray-600 font-bold py-1 px-2">Aisle {{ hdr }}</div>
<ul>
{% for e in entries %}
<li>{{ e.entry.product.name }}{% if e.bin %} (Bin {{e.bin}}){% endif %}</li>
{% endfor %}
</ul>
{% endfor %}
{% endif %}
{% endif %}
<form action="{{ url_for('lists.delete', id=list.id) }}" method="post" id="delete-list-form"></form>
<script language="javascript">
function changeSorting(e) {
const value = e.target.value;
const [ sort, store ] = value.split(':');
if (store) {
window.location = `?sort=${encodeURIComponent(sort)}&store=${encodeURIComponent(store)}`;
} else {
window.location = `?sort=${encodeURIComponent(sort)}`;
}
}
function deleteList() {
const form = document.getElementById('delete-list-form');
if (form && confirm('Are you sure you want to delete list "{{ list.name }}"? This cannot be undone.')) {
form.submit();
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,28 @@
{% extends 'base.html.j2' %}
{% block title %}Home | Sigl{% endblock %}
{% block header %}
<div class="flex justify-between items-center py-2">
<div class="text-lg font-bold text-gray-800">All Lists</div>
<a href="{{ url_for('lists.create') }}" class="px-2 py-1 text-sm text-white bg-green-600 hover:bg-green-700 border rounded flex justify-between items-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-1" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
New List
</a>
</div>
{% endblock %}
{% block main %}
{% if lists|length == 0 %}
<div class="py-2 border-b border-gray-300">
<div class="ml-4 text-sm">No shopping lists</div>
</div>
{% else %}
<ul class="w-full">
{% for lst in lists %}
<li class="block w-full py-2{% if not loop.last %} border-b border-gray-300{% endif %}">
<a href="{{ url_for('lists.detail', id=lst.id) }}"><div class="px-4">{{ lst.name }} ({{ lst.entries|length }} Items)</div></a>
</li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

17
sigl/views/__init__.py Normal file
View File

@@ -0,0 +1,17 @@
"""Sigl View Blueprints.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from .lists import bp as list_bp
__all__ = ('init_views', )
def init_views(app):
"""Register the View Blueprints with the Application."""
app.register_blueprint(list_bp)
# Notify Initialization Complete
app.logger.debug('Views Initialized')

116
sigl/views/lists.py Normal file
View File

@@ -0,0 +1,116 @@
"""Sigl Shopping List View Blueprint.
Simple Grocery List (Sigl) | sigl.app
Copyright (c) 2022 Asymworks, LLC. All Rights Reserved.
"""
from flask import (
Blueprint,
abort, flash, redirect, render_template, request, url_for
)
from sigl.exc import Error
from sigl.database import db
from sigl.domain.service import (
lists_all, list_by_id, list_create, list_delete,
list_stores,
)
__all__ = ('bp', )
#: Lists Blueprint
bp = Blueprint('lists', __name__)
@bp.route('/')
@bp.route('/lists')
def home():
"""Sigl Home Page / All Shopping Lists View."""
lists = lists_all(db.session)
return render_template('lists/home.html.j2', lists=lists)
@bp.route('/lists/new', methods=('GET', 'POST'))
def create():
"""Create Shopping List View."""
if request.method == 'POST':
list_name = request.form['name']
list_notes = request.form['notes']
if not list_name:
flash('Error: List Name is required')
return render_template('lists/create.html.j2')
list = list_create(db.session, list_name, notes=list_notes)
return redirect(url_for('lists.detail', id=list.id))
else:
return render_template('lists/create.html.j2')
@bp.route('/lists/<int:id>')
def detail(id):
"""Shopping List Detail View."""
try:
sList = list_by_id(db.session, id)
sortBy = request.args.get('sort', 'none')
sortStore = request.args.get('store', '')
if sortBy not in ('none', 'category', 'store'):
flash(f'Invalid sorting mode {sortBy}', 'warning')
sortBy = 'category'
groups = dict()
for e in sList.entries:
if sortBy == 'category':
category = e.product.category or 'Uncategorized'
if category not in groups:
groups[category] = [{'entry': e}]
else:
groups[category].append({'entry': e})
elif sortBy == 'store':
aisle = 'Unknown'
bin = None
locs = e.product.locations
for l in locs:
if l.store.lower() == sortStore.lower():
aisle = l.aisle
bin = l.bin
if aisle not in groups:
groups[aisle] = [{'entry': e, 'bin': bin}]
else:
groups[aisle].append({'entry': e, 'bin': bin})
else:
category = 'Unsorted'
if category not in groups:
groups[category] = [{'entry': e}]
else:
groups[category].append({'entry': e})
flash('An error occurred during the processing of this request', 'error')
return render_template(
'lists/detail.html.j2',
list=list_by_id(db.session, id),
sortBy=sortBy,
sortStore=sortStore,
groups=groups,
stores=list_stores(db.session, id),
)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.home'))
@bp.route('/lists/<int:id>/delete', methods=('POST', ))
def delete(id):
"""Delete a Shopping List."""
try:
list_delete(db.session, id)
except Error as e:
flash(str(e), 'error')
return redirect(url_for('lists.home'))