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)
|
167
app.py
167
app.py
@ -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
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>
|
</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 %}
|
@ -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>
|
@ -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>
|
@ -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 %}
|
@ -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
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