Add WebManifest, base social metadata, noindex on non-pages; Add /api/items and adapt storing to support it; Improve auth handling and add session verification; Fix #11; Improve editing page with image paste from clipboard and file upload preview; Add footer; Autogenerate config

This commit is contained in:
2025-07-14 22:52:22 +02:00
parent 0a8f5269e4
commit 3b1dcec778
9 changed files with 262 additions and 104 deletions

11
_util.py Normal file
View File

@ -0,0 +1,11 @@
def read_textual(filepath:str) -> str:
try:
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
with open(filepath, "r") as f:
return f.read()
def write_textual(filepath:str, content:str):
with open(filepath, "w", encoding="utf-8") as f:
return f.write(content)

167
app.py
View File

@ -1,11 +1,13 @@
import os import os
import requests import requests
import urllib.parse import urllib.parse
from typing import Any from functools import wraps
from typing import Any, cast
from base64 import b64decode, urlsafe_b64encode
from io import StringIO from io import StringIO
from configparser import ConfigParser from configparser import ConfigParser
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from flask import Flask, request, redirect, render_template, send_from_directory, abort, url_for, flash from flask import Flask, request, redirect, render_template, send_from_directory, abort, url_for, flash, session, make_response
from flask_bcrypt import Bcrypt # type: ignore[import-untyped] from flask_bcrypt import Bcrypt # type: ignore[import-untyped]
from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required # type: ignore[import-untyped] from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required # type: ignore[import-untyped]
from flask_wtf import FlaskForm # type: ignore[import-untyped] from flask_wtf import FlaskForm # type: ignore[import-untyped]
@ -15,19 +17,35 @@ from glob import glob
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from snowflake import Snowflake, SnowflakeGenerator # type: ignore[import-untyped] from snowflake import Snowflake, SnowflakeGenerator # type: ignore[import-untyped]
from hashlib import sha256
from _util import *
SECRET_KEY = "SECRET_KEY" # import secrets; print(secrets.token_urlsafe()) # config #
DEVELOPMENT = True DEVELOPMENT = False
HTTP_PORT = 5000 HTTP_PORT = 5000
HTTP_THREADS = 32 HTTP_THREADS = 32
LINKS_PREFIX = "" LINKS_PREFIX = ""
# endconfig #
from _config import * try:
from _config import *
except ModuleNotFoundError:
# print("Configuration file not found! Generating...")
from secrets import token_urlsafe
config = read_textual(__file__).split("# config #")[1].split("# endconfig #")[0].strip()
write_textual("_config.py", f"""\
SECRET_KEY = "{token_urlsafe()}"
{config}
""")
# print("Saved configuration to _config.py. Exiting!")
# exit()
from _config import *
app = Flask(__name__) app = Flask(__name__)
app.config["LINKS_PREFIX"] = LINKS_PREFIX app.config["LINKS_PREFIX"] = LINKS_PREFIX
app.config["APP_NAME"] = "Pignio" app.config["APP_NAME"] = "Pignio"
app.config["APP_ICON"] = "📌" app.config["APP_ICON"] = "📌"
app.config["DEVELOPMENT"] = DEVELOPMENT
app.config["SECRET_KEY"] = SECRET_KEY app.config["SECRET_KEY"] = SECRET_KEY
app.config["BCRYPT_HANDLE_LONG_PASSWORDS"] = True app.config["BCRYPT_HANDLE_LONG_PASSWORDS"] = True
@ -43,8 +61,9 @@ DATA_ROOT = "data"
ITEMS_ROOT = f"{DATA_ROOT}/items" ITEMS_ROOT = f"{DATA_ROOT}/items"
USERS_ROOT = f"{DATA_ROOT}/users" USERS_ROOT = f"{DATA_ROOT}/users"
EXTENSIONS = { EXTENSIONS = {
"images": ("jpg", "jpeg", "png", "gif", "webp", "avif"), "image": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
"videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"), "video": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
"audio": ("mp3", "m4a", "flac", "opus", "ogg", "wav"),
} }
ITEMS_EXT = ".ini" ITEMS_EXT = ".ini"
@ -62,11 +81,27 @@ class LoginForm(FlaskForm):
password = PasswordField("Password", validators=[DataRequired()]) password = PasswordField("Password", validators=[DataRequired()])
submit = SubmitField("Login") submit = SubmitField("Login")
def noindex(view_func):
@wraps(view_func)
def wrapped_view(*args, **kwargs):
response = make_response(view_func(*args, **kwargs))
response.headers["X-Robots-Tag"] = "noindex"
return response
return wrapped_view
@app.route("/") @app.route("/")
def index(): def index():
return render_template("index.html", items=walk_items()) return render_template("index.html", items=walk_items())
@app.route("/manifest.json")
@noindex
def serve_manifest():
response = make_response(render_template("manifest.json"))
response.headers["Content-Type"] = "application/json"
return response
@app.route("/static/module/<path:module>/<path:filename>") @app.route("/static/module/<path:module>/<path:filename>")
@noindex
def serve_module(module:str, filename:str): def serve_module(module:str, filename:str):
return send_from_directory(os.path.join("node_modules", module, "dist"), filename) return send_from_directory(os.path.join("node_modules", module, "dist"), filename)
@ -105,6 +140,7 @@ def search():
return render_template("search.html", items=(results if found else None), query=query) return render_template("search.html", items=(results if found else None), query=query)
@app.route("/add", methods=["GET", "POST"]) @app.route("/add", methods=["GET", "POST"])
@noindex
@login_required @login_required
def add_item(): def add_item():
item = {} item = {}
@ -116,22 +152,22 @@ def add_item():
elif request.method == "POST": elif request.method == "POST":
iid = request.form.get("id") or generate_iid() iid = request.form.get("id") or generate_iid()
data = {key: request.form[key] for key in ["link", "title", "description", "image", "text"]}
if store_item(iid, data, request.files): if store_item(iid, request.form, request.files):
return redirect(url_for("view_item", iid=iid)) return redirect(url_for("view_item", iid=iid))
else: else:
flash("Cannot save item", "danger") flash("Cannot save item", "danger")
return render_template("add.html", item=item) return render_template("add.html", item=item)
@app.route("/remove", methods=["GET", "POST"]) @app.route("/delete", methods=["GET", "POST"])
@noindex
@login_required @login_required
def remove_item(): def remove_item():
if request.method == "GET": if request.method == "GET":
if (iid := request.args.get("item")): if (iid := request.args.get("item")):
if (item := load_item(iid)): if (item := load_item(iid)):
return render_template("remove.html", item=item) return render_template("delete.html", item=item)
elif request.method == "POST": elif request.method == "POST":
if (iid := request.form.get("id")): if (iid := request.form.get("id")):
@ -141,16 +177,12 @@ def remove_item():
abort(404) abort(404)
@app.route("/api/preview")
@login_required
def link_preview():
return fetch_url_data(request.args.get("url"))
@app.errorhandler(404) @app.errorhandler(404)
def error_404(e): def error_404(e):
return render_template("404.html"), 404 return render_template("404.html"), 404
@app.route("/login", methods=["GET", "POST"]) @app.route("/login", methods=["GET", "POST"])
@noindex
def login(): def login():
if current_user.is_authenticated: if current_user.is_authenticated:
return redirect(url_for("index")) return redirect(url_for("index"))
@ -166,6 +198,7 @@ def login():
if pass_equals: if pass_equals:
user.data["password"] = bcrypt.generate_password_hash(user.data["password"]).decode("utf-8") user.data["password"] = bcrypt.generate_password_hash(user.data["password"]).decode("utf-8")
write_textual(user.filepath, write_metadata(user.data)) write_textual(user.filepath, write_metadata(user.data))
session["session_hash"] = generate_user_hash(user.username, user.data["password"])
login_user(user) login_user(user)
# next_url = flask.request.args.get('next') # next_url = flask.request.args.get('next')
# if not url_has_allowed_host_and_scheme(next_url, request.host): return flask.abort(400) # if not url_has_allowed_host_and_scheme(next_url, request.host): return flask.abort(400)
@ -176,17 +209,58 @@ def login():
return render_template("login.html", form=form) return render_template("login.html", form=form)
@app.route("/logout") @app.route("/logout")
@noindex
def logout(): def logout():
if current_user.is_authenticated: if current_user.is_authenticated:
logout_user() logout_user()
return redirect(url_for("index")) return redirect(url_for("index"))
@app.route("/api/preview")
@noindex
@login_required
def link_preview():
return fetch_url_data(request.args.get("url"))
@app.route("/api/items", defaults={'iid': None}, methods=["POST"])
@app.route("/api/items/<path:iid>", methods=["GET", "PUT", "DELETE"])
@noindex
@login_required
def items_api(iid:str):
if request.method == "GET":
return load_item(iid)
elif request.method == "POST" or request.method == "PUT":
iid = iid or generate_iid()
status = store_item(iid, request.get_json())
return {"id": iid if status else None}
elif request.method == "DELETE":
delete_item(iid)
return {}
@login_manager.user_loader @login_manager.user_loader
def load_user(username:str): def load_user(username:str):
filepath = os.path.join(USERS_ROOT, (username + ITEMS_EXT)) filepath = os.path.join(USERS_ROOT, (username + ITEMS_EXT))
if os.path.exists(filepath): if os.path.exists(filepath):
return User(username, filepath) return User(username, filepath)
@login_manager.unauthorized_handler
def unauthorized():
if request.path.startswith("/api/"):
return {"error": "Unauthorized"}, 401
else:
flash("Please log in to access this page.")
return redirect(url_for("login"))
@app.before_request
def validate_session():
if current_user.is_authenticated:
expected_hash = generate_user_hash(current_user.username, current_user.data["password"])
if session.get("session_hash") != expected_hash:
logout_user()
def generate_user_hash(username:str, password:str):
text = f"{username}:{password}"
return urlsafe_b64encode(sha256(text.encode()).digest()).decode()
def walk_items(): def walk_items():
results, iids = {}, {} results, iids = {}, {}
@ -257,33 +331,45 @@ def load_item(iid:str):
if file.lower().endswith(ITEMS_EXT): if file.lower().endswith(ITEMS_EXT):
data = data | read_metadata(read_textual(file)) data = data | read_metadata(read_textual(file))
elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])): elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["image"]])):
data["image"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/") data["image"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/")
return data return data
def store_item(iid:str, data:dict, files:dict): def store_item(iid:str, data:dict, files:dict|None=None):
iid = filename_to_iid(iid) iid = filename_to_iid(iid)
existing = load_item(iid) existing = load_item(iid)
filename = split_iid(iid_to_filename(iid)) filename = split_iid(iid_to_filename(iid))
filepath = os.path.join(ITEMS_ROOT, *filename) filepath = os.path.join(ITEMS_ROOT, *filename)
mkdirs(os.path.join(ITEMS_ROOT, filename[0])) mkdirs(os.path.join(ITEMS_ROOT, filename[0]))
image = False image = False
data = {key: data[key] for key in ["link", "title", "description", "image", "text"] if key in data}
if len(files): if files and len(files):
file = files["file"] file = files["file"]
if file.seek(0, os.SEEK_END): if file.seek(0, os.SEEK_END):
file.seek(0, os.SEEK_SET) file.seek(0, os.SEEK_SET)
ext = file.content_type.split("/")[1] mime = file.content_type.split("/")
file.save(f"{filepath}.{ext}") ext = mime[1]
image = True if mime[0] == "image" and ext in EXTENSIONS["image"]:
if not image and data["image"]: file.save(f"{filepath}.{ext}")
response = requests.get(data["image"], timeout=5) image = True
ext = response.headers["Content-Type"].split("/")[1] if not image and "image" in data and data["image"]:
with open(f"{filepath}.{ext}", "wb") as f: if data["image"].lower().startswith("data:image/"):
f.write(response.content) ext = data["image"].lower().split(";")[0].split("/")[1]
image = True if ext in EXTENSIONS["image"]:
if not (existing or image or data["text"]): with open(f"{filepath}.{ext}", "wb") as f:
f.write(b64decode(data["image"].split(",")[1]))
image = True
else:
response = requests.get(data["image"], timeout=5)
mime = response.headers["Content-Type"].split("/")
ext = mime[1]
if mime[0] == "image" and ext in EXTENSIONS["image"]:
with open(f"{filepath}.{ext}", "wb") as f:
f.write(response.content)
image = True
if not (existing or image or ("text" in data and data["text"])):
return False return False
if existing: if existing:
@ -299,8 +385,9 @@ def store_item(iid:str, data:dict, files:dict):
write_textual(filepath + ITEMS_EXT, write_metadata(data)) write_textual(filepath + ITEMS_EXT, write_metadata(data))
return True return True
def delete_item(item:dict): def delete_item(item:dict|str):
filepath = os.path.join(ITEMS_ROOT, iid_to_filename(item["id"])) iid = cast(str, item["id"] if type(item) == dict else item)
filepath = os.path.join(ITEMS_ROOT, iid_to_filename(iid))
files = glob(f"{filepath}.*") files = glob(f"{filepath}.*")
for file in files: for file in files:
os.remove(file) os.remove(file)
@ -327,18 +414,6 @@ def write_metadata(data:dict) -> str:
config.write(output) config.write(output)
return "\n".join(output.getvalue().splitlines()[1:]) # remove section header return "\n".join(output.getvalue().splitlines()[1:]) # remove section header
def read_textual(filepath:str) -> str:
try:
with open(filepath, "r", encoding="utf-8") as f:
return f.read()
except UnicodeDecodeError:
with open(filepath, "r") as f:
return f.read()
def write_textual(filepath:str, content:str):
with open(filepath, "w", encoding="utf-8") as f:
return f.write(content)
def fetch_url_data(url:str): def fetch_url_data(url:str):
response = requests.get(url, timeout=5) response = requests.get(url, timeout=5)
soup = BeautifulSoup(response.text, "html.parser") soup = BeautifulSoup(response.text, "html.parser")
@ -362,11 +437,9 @@ def fetch_url_data(url:str):
"link": soup_or_default(soup, "link", {"rel": "canonical"}, "href", url), "link": soup_or_default(soup, "link", {"rel": "canonical"}, "href", url),
} }
def prop_or_default(items:Any, prop:str, default):
return (items[prop] if (items and prop in items) else None) or default
def soup_or_default(soup:BeautifulSoup, tag:str, attrs:dict, prop:str, default): def soup_or_default(soup:BeautifulSoup, tag:str, attrs:dict, prop:str, default):
return prop_or_default(soup.find(tag, attrs=attrs), prop, default) elem = soup.find(tag, attrs=attrs)
return (elem.get(prop) if elem else None) or default # type: ignore[attr-defined]
def generate_iid() -> str: def generate_iid() -> str:
return str(next(snowflake)) return str(next(snowflake))

57
static/add.js Normal file
View File

@ -0,0 +1,57 @@
var link = document.querySelector('form input[name="link"]');
var check = document.querySelector('form input[type="checkbox"]');
var image = document.querySelector('form img.image');
var upload = document.querySelector('form input[name="file"]');
upload.addEventListener('change', function(ev) {
const file = ev.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
image.src = e.target.result;
image.parentElement.hidden = false;
};
reader.readAsDataURL(file);
});
document.addEventListener('paste', function(ev) {
const items = (ev.clipboardData || ev.originalEvent.clipboardData).items;
for (let item of items) {
if (item.type.indexOf('image') !== -1) {
const file = item.getAsFile();
const reader = new FileReader();
reader.onload = function(e) {
image.src = e.target.result;
image.parentElement.hidden = false;
const dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
upload.files = dataTransfer.files;
};
reader.readAsDataURL(file);
break;
}
}
});
['change', 'input', 'paste'].forEach(handler => {
link.addEventListener(handler, () => {
var url = link.value.trim();
if (check.checked && url) {
fetch('../api/preview?url=' + encodeURIComponent(url))
.then(res => res.json())
.then(data => {
for (var key in data) {
var field = document.querySelector(`form [name="${key}"]`);
if (field) {
field.value = data[key];
}
var el = document.querySelector(`form [class="${key}"]`);
if (el) {
el.src = data[key];
el.parentElement.hidden = false;
}
}
})
}
})
});

View File

@ -24,10 +24,10 @@
</label> </label>
</div> </div>
<div class="uk-margin"> <div class="uk-margin">
<div uk-grid> <div class="uk-grid-collapse" uk-grid>
<div uk-form-custom class="uk-width-1-1"> <div uk-form-custom class="uk-width-1-1">
<input class="uk-input" type="file" name="file" /> <input class="uk-input" type="file" name="file" />
<button class="uk-button uk-width-1-1 uk-button-default" type="button" tabindex="-1">Select image</button> <button class="uk-button uk-width-1-1 uk-button-default" type="button" tabindex="-1">Select or drop an image file</button>
</div> </div>
</div> </div>
</div> </div>
@ -50,30 +50,5 @@
<button class="uk-input uk-button uk-button-primary" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button> <button class="uk-input uk-button uk-button-primary" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button>
</div> </div>
</form> </form>
<script> <script src="{{ url_for('static', filename='add.js') }}"></script>
var link = document.querySelector('form input[name="link"]');
var check = document.querySelector('form input[type="checkbox"]');
['change', 'input', 'paste'].forEach(handler => {
link.addEventListener(handler, () => {
var url = link.value.trim();
if (check.checked && url) {
fetch('{{ url_for("link_preview") }}?url=' + encodeURIComponent(url))
.then(res => res.json())
.then(data => {
for (var key in data) {
var field = document.querySelector(`form [name="${key}"]`);
if (field) {
field.value = data[key];
}
var el = document.querySelector(`form [class="${key}"]`);
if (el) {
el.src = data[key];
el.parentElement.hidden = false;
}
}
})
}
})
});
</script>
{% endblock %} {% endblock %}

View File

@ -11,15 +11,25 @@
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script> <script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
{% if canonical %} {% if canonical %}
<link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" /> <link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" />
<meta name="og:url" content="{{ config.LINKS_PREFIX }}{{ canonical }}" />
{% endif %} {% endif %}
<meta name="og:site_name" content="{{ config.APP_NAME }}" />
{% block metadata %}{% endblock %}
<link rel="manifest" href="{{ url_for('serve_manifest') }}" />
<meta name="generator" content="{{ config.APP_NAME }}" />
</head> </head>
<body> <body class="uk-height-viewport uk-flex uk-flex-column">
<nav class="uk-navbar-container" uk-sticky="sel-target: .uk-navbar-container; cls-active: uk-navbar-sticky"> <nav class="uk-navbar-container" uk-sticky="sel-target: .uk-navbar-container; cls-active: uk-navbar-sticky">
<div class="uk-container uk-container-expand" uk-navbar> <div class="uk-container uk-container-expand" uk-navbar>
<div class="uk-navbar-left uk-width-expand"> <div class="uk-navbar-left uk-width-expand">
<a class="uk-logo" href="{{ url_for('index') }}"> <a class="uk-link-reset" href="{{ url_for('index') }}">
<span uk-icon="icon: home"></span> <span class="uk-logo">
{{ config.APP_NAME }} <span uk-icon="icon: home"></span>
{{ config.APP_NAME }}
</span>
{%if config.DEVELOPMENT %}
<span class="uk-text-small">DEV</span>
{% endif %}
</a> </a>
<form class="uk-search uk-search-navbar uk-width-auto uk-flex-1" action="{{ url_for('search') }}"> <form class="uk-search uk-search-navbar uk-width-auto uk-flex-1" action="{{ url_for('search') }}">
<input class="uk-search-input" type="search" name="query" placeholder="Search..." value="{{ query }}" required> <input class="uk-search-input" type="search" name="query" placeholder="Search..." value="{{ query }}" required>
@ -27,21 +37,38 @@
</form> </form>
</div> </div>
<div class="uk-navbar-right uk-margin-left"> <div class="uk-navbar-right uk-margin-left">
<a class="uk-button uk-button-default uk-icon-link" uk-icon="plus" href="{{ url_for('add_item') }}"> <a class="uk-button uk-button-default uk-icon-link" uk-icon="plus" href="{{ url_for('add_item') }}" rel="nofollow">
<span class="uk-visible@s">Create</span> <span class="uk-visible@s">Create</span>
</a> </a>
</div> </div>
</div> </div>
</nav> </nav>
<div class="uk-container uk-margin"> <div class="uk-flex uk-flex-center uk-margin uk-flex-auto">
{% with messages = get_flashed_messages(with_categories=true) %} <div class="uk-container uk-flex-auto">
{% if messages %} {% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %} {% if messages %}
<p uk-alert class="uk-alert-{{ category }}">{{ message }}</p> {% for category, message in messages %}
{% endfor %} <p uk-alert class="uk-alert-{{ category }}">{{ message }}</p>
{% endif %} {% endfor %}
{% endwith %} {% endif %}
{% block content %}{% endblock %} {% endwith %}
{% block content %}{% endblock %}
</div>
</div> </div>
<footer class="uk-section uk-section-xsmall uk-background-muted">
<div class="uk-container">
<p class="uk-text-meta">
Powered by <a class="uk-text-primary" target="_blank" href="https://gitlab.com/octospacc/Pignio">{{ config.APP_NAME }}</a>, © 2025 OctoSpacc
<span class="uk-float-right">
{% if current_user.is_authenticated %}
Logged in as <a href="{{ url_for('view_user', username=current_user.username) }}">{{ current_user.username }}</a>
<a class="uk-button uk-button-text uk-text-baseline" href="{{ url_for('logout') }}">Logout</a>
{% else %}
<a class="uk-button uk-button-text uk-text-baseline" href="{{ url_for('login') }}">Login</a>
{% endif %}
</span>
</p>
</div>
</footer>
</body> </body>
</html> </html>

View File

@ -1,5 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}🗑 Remove item{% endblock %} {% block title %}🗑 Delete item{% endblock %}
{% block content %} {% block content %}
<form method="POST"> <form method="POST">
<input type="hidden" name="id" value="{{ item.id }}" /> <input type="hidden" name="id" value="{{ item.id }}" />
@ -8,7 +8,7 @@
<a class="uk-button uk-button-default" href="{{ url_for('view_item', iid=item.id) }}">Back</a> <a class="uk-button uk-button-default" href="{{ url_for('view_item', iid=item.id) }}">Back</a>
</div> </div>
<div> <div>
<button class="uk-input uk-button uk-button-danger" type="submit">Remove {{ item.id }}</button> <button class="uk-input uk-button uk-button-danger" type="submit">Delete {{ item.id }}</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -1,6 +1,8 @@
<div> {% if item %}
<a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}"> <div>
{% include "item-content.html" %} <a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}">
<span class="uk-text-break">{% include "item-title.html" %}</span> {% include "item-content.html" %}
</a> <span class="uk-text-break">{% include "item-title.html" %}</span>
</div> </a>
</div>
{% endif %}

View File

@ -1,6 +1,18 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}{% include "item-title.html" %}{% endblock %} {% block title %}{% include "item-title.html" %}{% endblock %}
{% block canonical %}{{ url_for('view_item', iid=item.id) }}{% endblock %} {% block canonical %}{{ url_for('view_item', iid=item.id) }}{% endblock %}
{% block metadata %}
<meta name="og:title" content="{{ title }}" />
<meta name="twitter:title" content="{{ title }}" />
<meta name="og:description" content="{{ item.description }}" />
<meta name="twitter:description" content="{{ item.description }}" />
<meta name="description" content="{{ item.description }}" />
{% if item.image %}
<meta name="og:image" content="{{ url_for('serve_media', filename=item.image) }}" />
<meta name="twitter:image" content="{{ url_for('serve_media', filename=item.image) }}" />
<meta name="twitter:card" content="summary_large_image" />
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="uk-flex uk-flex-wrap"> <div class="uk-flex uk-flex-wrap">
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small"> <div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
@ -15,18 +27,14 @@
</div> </div>
<p>{{ item.description }}</p> <p>{{ item.description }}</p>
<div class="uk-margin"> <div class="uk-margin">
<a class="uk-button uk-button-default uk-icon-link" href="{{ url_for('add_item') }}?item={{ item.id }}"> <a class="uk-button uk-button-default uk-icon-link" href="{{ url_for('add_item') }}?item={{ item.id }}" rel="nofollow">
Edit Edit
<span uk-icon="icon: file-edit"></span> <span uk-icon="icon: file-edit"></span>
</a> </a>
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}"> <a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}" rel="nofollow">
Remove Delete
<span uk-icon="icon: trash"></span> <span uk-icon="icon: trash"></span>
</a> </a>
<!-- <form class="uk-inline" method="POST" action="{{ url_for('remove_item') }}">
<input type="hidden" name="item" value="{{ item.id }}" />
<button class="uk-input uk-button uk-button-danger" type="submit">Remove</button>
</form> -->
</div> </div>
</div> </div>
</div> </div>

5
templates/manifest.json Normal file
View File

@ -0,0 +1,5 @@
{
"name": "{{ config.APP_NAME }}",
"start_url": "{{ url_for('index') }}",
"display": "standalone"
}