mirror of
https://gitlab.com/octospacc/Pignio.git
synced 2025-07-17 22:37:38 +02:00
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:
11
_util.py
Normal file
11
_util.py
Normal 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)
|
153
app.py
153
app.py
@ -1,11 +1,13 @@
|
||||
import os
|
||||
import requests
|
||||
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 configparser import ConfigParser
|
||||
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_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]
|
||||
@ -15,19 +17,35 @@ from glob import glob
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
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())
|
||||
DEVELOPMENT = True
|
||||
# config #
|
||||
DEVELOPMENT = False
|
||||
HTTP_PORT = 5000
|
||||
HTTP_THREADS = 32
|
||||
LINKS_PREFIX = ""
|
||||
# endconfig #
|
||||
|
||||
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.config["LINKS_PREFIX"] = LINKS_PREFIX
|
||||
app.config["APP_NAME"] = "Pignio"
|
||||
app.config["APP_ICON"] = "📌"
|
||||
app.config["DEVELOPMENT"] = DEVELOPMENT
|
||||
app.config["SECRET_KEY"] = SECRET_KEY
|
||||
app.config["BCRYPT_HANDLE_LONG_PASSWORDS"] = True
|
||||
|
||||
@ -43,8 +61,9 @@ DATA_ROOT = "data"
|
||||
ITEMS_ROOT = f"{DATA_ROOT}/items"
|
||||
USERS_ROOT = f"{DATA_ROOT}/users"
|
||||
EXTENSIONS = {
|
||||
"images": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
|
||||
"videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
|
||||
"image": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
|
||||
"video": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
|
||||
"audio": ("mp3", "m4a", "flac", "opus", "ogg", "wav"),
|
||||
}
|
||||
ITEMS_EXT = ".ini"
|
||||
|
||||
@ -62,11 +81,27 @@ class LoginForm(FlaskForm):
|
||||
password = PasswordField("Password", validators=[DataRequired()])
|
||||
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("/")
|
||||
def index():
|
||||
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>")
|
||||
@noindex
|
||||
def serve_module(module:str, filename:str):
|
||||
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)
|
||||
|
||||
@app.route("/add", methods=["GET", "POST"])
|
||||
@noindex
|
||||
@login_required
|
||||
def add_item():
|
||||
item = {}
|
||||
@ -116,22 +152,22 @@ def add_item():
|
||||
|
||||
elif request.method == "POST":
|
||||
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))
|
||||
else:
|
||||
flash("Cannot save item", "danger")
|
||||
|
||||
return render_template("add.html", item=item)
|
||||
|
||||
@app.route("/remove", methods=["GET", "POST"])
|
||||
@app.route("/delete", methods=["GET", "POST"])
|
||||
@noindex
|
||||
@login_required
|
||||
def remove_item():
|
||||
if request.method == "GET":
|
||||
if (iid := request.args.get("item")):
|
||||
if (item := load_item(iid)):
|
||||
return render_template("remove.html", item=item)
|
||||
return render_template("delete.html", item=item)
|
||||
|
||||
elif request.method == "POST":
|
||||
if (iid := request.form.get("id")):
|
||||
@ -141,16 +177,12 @@ def remove_item():
|
||||
|
||||
abort(404)
|
||||
|
||||
@app.route("/api/preview")
|
||||
@login_required
|
||||
def link_preview():
|
||||
return fetch_url_data(request.args.get("url"))
|
||||
|
||||
@app.errorhandler(404)
|
||||
def error_404(e):
|
||||
return render_template("404.html"), 404
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
@noindex
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("index"))
|
||||
@ -166,6 +198,7 @@ def login():
|
||||
if pass_equals:
|
||||
user.data["password"] = bcrypt.generate_password_hash(user.data["password"]).decode("utf-8")
|
||||
write_textual(user.filepath, write_metadata(user.data))
|
||||
session["session_hash"] = generate_user_hash(user.username, user.data["password"])
|
||||
login_user(user)
|
||||
# next_url = flask.request.args.get('next')
|
||||
# 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)
|
||||
|
||||
@app.route("/logout")
|
||||
@noindex
|
||||
def logout():
|
||||
if current_user.is_authenticated:
|
||||
logout_user()
|
||||
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
|
||||
def load_user(username:str):
|
||||
filepath = os.path.join(USERS_ROOT, (username + ITEMS_EXT))
|
||||
if os.path.exists(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():
|
||||
results, iids = {}, {}
|
||||
|
||||
@ -257,33 +331,45 @@ def load_item(iid:str):
|
||||
if file.lower().endswith(ITEMS_EXT):
|
||||
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}/")
|
||||
|
||||
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)
|
||||
existing = load_item(iid)
|
||||
filename = split_iid(iid_to_filename(iid))
|
||||
filepath = os.path.join(ITEMS_ROOT, *filename)
|
||||
mkdirs(os.path.join(ITEMS_ROOT, filename[0]))
|
||||
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"]
|
||||
if file.seek(0, os.SEEK_END):
|
||||
file.seek(0, os.SEEK_SET)
|
||||
ext = file.content_type.split("/")[1]
|
||||
mime = file.content_type.split("/")
|
||||
ext = mime[1]
|
||||
if mime[0] == "image" and ext in EXTENSIONS["image"]:
|
||||
file.save(f"{filepath}.{ext}")
|
||||
image = True
|
||||
if not image and data["image"]:
|
||||
if not image and "image" in data and data["image"]:
|
||||
if data["image"].lower().startswith("data:image/"):
|
||||
ext = data["image"].lower().split(";")[0].split("/")[1]
|
||||
if ext in EXTENSIONS["image"]:
|
||||
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)
|
||||
ext = response.headers["Content-Type"].split("/")[1]
|
||||
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 data["text"]):
|
||||
if not (existing or image or ("text" in data and data["text"])):
|
||||
return False
|
||||
|
||||
if existing:
|
||||
@ -299,8 +385,9 @@ def store_item(iid:str, data:dict, files:dict):
|
||||
write_textual(filepath + ITEMS_EXT, write_metadata(data))
|
||||
return True
|
||||
|
||||
def delete_item(item:dict):
|
||||
filepath = os.path.join(ITEMS_ROOT, iid_to_filename(item["id"]))
|
||||
def delete_item(item:dict|str):
|
||||
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}.*")
|
||||
for file in files:
|
||||
os.remove(file)
|
||||
@ -327,18 +414,6 @@ def write_metadata(data:dict) -> str:
|
||||
config.write(output)
|
||||
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):
|
||||
response = requests.get(url, timeout=5)
|
||||
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),
|
||||
}
|
||||
|
||||
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):
|
||||
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:
|
||||
return str(next(snowflake))
|
||||
|
57
static/add.js
Normal file
57
static/add.js
Normal 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;
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
@ -24,10 +24,10 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="uk-margin">
|
||||
<div uk-grid>
|
||||
<div class="uk-grid-collapse" uk-grid>
|
||||
<div uk-form-custom class="uk-width-1-1">
|
||||
<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>
|
||||
@ -50,30 +50,5 @@
|
||||
<button class="uk-input uk-button uk-button-primary" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
<script src="{{ url_for('static', filename='add.js') }}"></script>
|
||||
{% endblock %}
|
@ -11,15 +11,25 @@
|
||||
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
|
||||
{% if canonical %}
|
||||
<link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" />
|
||||
<meta name="og:url" content="{{ config.LINKS_PREFIX }}{{ canonical }}" />
|
||||
{% 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>
|
||||
<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">
|
||||
<div class="uk-container uk-container-expand" uk-navbar>
|
||||
<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 class="uk-logo">
|
||||
<span uk-icon="icon: home"></span>
|
||||
{{ config.APP_NAME }}
|
||||
</span>
|
||||
{%if config.DEVELOPMENT %}
|
||||
<span class="uk-text-small">DEV</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
<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>
|
||||
@ -27,13 +37,14 @@
|
||||
</form>
|
||||
</div>
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="uk-container uk-margin">
|
||||
<div class="uk-flex uk-flex-center uk-margin uk-flex-auto">
|
||||
<div class="uk-container uk-flex-auto">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
@ -43,5 +54,21 @@
|
||||
{% endwith %}
|
||||
{% block content %}{% endblock %}
|
||||
</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>
|
||||
</html>
|
@ -1,5 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}🗑 Remove item{% endblock %}
|
||||
{% block title %}🗑 Delete item{% endblock %}
|
||||
{% block content %}
|
||||
<form method="POST">
|
||||
<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>
|
||||
</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>
|
||||
</form>
|
@ -1,6 +1,8 @@
|
||||
{% if item %}
|
||||
<div>
|
||||
<a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}">
|
||||
{% include "item-content.html" %}
|
||||
<span class="uk-text-break">{% include "item-title.html" %}</span>
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
@ -1,6 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{% include "item-title.html" %}{% 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 %}
|
||||
<div class="uk-flex uk-flex-wrap">
|
||||
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
|
||||
@ -15,18 +27,14 @@
|
||||
</div>
|
||||
<p>{{ item.description }}</p>
|
||||
<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
|
||||
<span uk-icon="icon: file-edit"></span>
|
||||
</a>
|
||||
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}">
|
||||
Remove
|
||||
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}" rel="nofollow">
|
||||
Delete
|
||||
<span uk-icon="icon: trash"></span>
|
||||
</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>
|
||||
|
5
templates/manifest.json
Normal file
5
templates/manifest.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "{{ config.APP_NAME }}",
|
||||
"start_url": "{{ url_for('index') }}",
|
||||
"display": "standalone"
|
||||
}
|
Reference in New Issue
Block a user