mirror of
https://gitlab.com/octospacc/Pignio.git
synced 2025-07-17 22:37:38 +02:00
Initial release version
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,2 +1,5 @@
|
|||||||
|
/_config.py
|
||||||
/data/
|
/data/
|
||||||
|
/node_modules/
|
||||||
|
/.mypy_cache/
|
||||||
*.pyc
|
*.pyc
|
313
app.py
313
app.py
@ -1,24 +1,36 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
|
||||||
import requests
|
import requests
|
||||||
import configparser
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import xml.etree.ElementTree as ElementTree
|
from typing import Any
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
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
|
||||||
from flask_bcrypt import Bcrypt
|
from flask_bcrypt import Bcrypt # type: ignore[import-untyped]
|
||||||
from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required
|
from flask_login import LoginManager, UserMixin, current_user, login_user, logout_user, login_required # type: ignore[import-untyped]
|
||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm # type: ignore[import-untyped]
|
||||||
from wtforms import StringField, PasswordField, SubmitField
|
from wtforms import StringField, PasswordField, SubmitField # type: ignore[import-untyped]
|
||||||
from wtforms.validators import DataRequired
|
from wtforms.validators import DataRequired # type: ignore[import-untyped]
|
||||||
from glob import glob
|
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
|
from snowflake import Snowflake, SnowflakeGenerator # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
SECRET_KEY = "SECRET_KEY" # import secrets; print(secrets.token_urlsafe())
|
||||||
|
DEVELOPMENT = True
|
||||||
|
HTTP_PORT = 5000
|
||||||
|
HTTP_THREADS = 32
|
||||||
|
LINKS_PREFIX = ""
|
||||||
|
|
||||||
|
from _config import *
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config["SECRET_KEY"] = "your_secret_key" # TODO: fix this for prod
|
app.config["LINKS_PREFIX"] = LINKS_PREFIX
|
||||||
|
app.config["APP_NAME"] = "Pignio"
|
||||||
|
app.config["APP_ICON"] = "📌"
|
||||||
|
app.config["SECRET_KEY"] = SECRET_KEY
|
||||||
|
app.config["BCRYPT_HANDLE_LONG_PASSWORDS"] = True
|
||||||
|
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
login_manager.login_view = "login"
|
login_manager.login_view = "login"
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
@ -30,19 +42,17 @@ snowflake = SnowflakeGenerator(1, epoch=snowflake_epoch)
|
|||||||
DATA_ROOT = "data"
|
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"
|
||||||
MEDIA_ROOT = f"{DATA_ROOT}/items"
|
|
||||||
EXTENSIONS = {
|
EXTENSIONS = {
|
||||||
"images": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
|
"images": ("jpg", "jpeg", "png", "gif", "webp", "avif"),
|
||||||
"videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
|
"videos": ("mp4", "mov", "mpeg", "ogv", "webm", "mkv"),
|
||||||
}
|
}
|
||||||
ITEMS_EXT = ".pignio"
|
ITEMS_EXT = ".ini"
|
||||||
|
|
||||||
class User(UserMixin):
|
class User(UserMixin):
|
||||||
def __init__(self, username, filepath):
|
def __init__(self, username, filepath):
|
||||||
self.username = username
|
self.username = username
|
||||||
self.filepath = filepath
|
self.filepath = filepath
|
||||||
with open(filepath, "r") as f:
|
self.data = read_metadata(read_textual(filepath))
|
||||||
self.data = read_metadata(f.read())
|
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
return self.username
|
return self.username
|
||||||
@ -54,11 +64,15 @@ class LoginForm(FlaskForm):
|
|||||||
|
|
||||||
@app.route("/")
|
@app.route("/")
|
||||||
def index():
|
def index():
|
||||||
return render_template("index.html", media=walk_items())
|
return render_template("index.html", items=walk_items())
|
||||||
|
|
||||||
|
@app.route("/static/module/<path:module>/<path:filename>")
|
||||||
|
def serve_module(module:str, filename:str):
|
||||||
|
return send_from_directory(os.path.join("node_modules", module, "dist"), filename)
|
||||||
|
|
||||||
@app.route("/media/<path:filename>")
|
@app.route("/media/<path:filename>")
|
||||||
def serve_media(filename:str):
|
def serve_media(filename:str):
|
||||||
return send_from_directory(MEDIA_ROOT, filename)
|
return send_from_directory(ITEMS_ROOT, filename)
|
||||||
|
|
||||||
@app.route("/item/<path:iid>")
|
@app.route("/item/<path:iid>")
|
||||||
def view_item(iid:str):
|
def view_item(iid:str):
|
||||||
@ -77,18 +91,18 @@ def view_user(username:str):
|
|||||||
@app.route("/search")
|
@app.route("/search")
|
||||||
def search():
|
def search():
|
||||||
query = request.args.get("query", "").lower()
|
query = request.args.get("query", "").lower()
|
||||||
|
found = False
|
||||||
results = {}
|
results = {}
|
||||||
|
|
||||||
for folder, items in walk_items().items():
|
for folder, items in walk_items().items():
|
||||||
results[folder] = []
|
results[folder] = []
|
||||||
|
|
||||||
for item in items:
|
for item in items:
|
||||||
image = item["id"]
|
if any([query in text.lower() for text in item.values()]):
|
||||||
meta = load_sider_metadata(image) or {}
|
results[folder].append(item)
|
||||||
if any([query in text.lower() for text in [image, *meta.values()]]):
|
found = True
|
||||||
results[folder].append(image)
|
|
||||||
|
|
||||||
return render_template("search.html", media=results, 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"])
|
||||||
@login_required
|
@login_required
|
||||||
@ -102,15 +116,12 @@ 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"]}
|
||||||
|
|
||||||
store_item(iid, {
|
if store_item(iid, data, request.files):
|
||||||
"link": request.form.get("link"),
|
|
||||||
"title": request.form.get("title"),
|
|
||||||
"description": request.form.get("description"),
|
|
||||||
"image": request.form.get("image"),
|
|
||||||
}, request.files)
|
|
||||||
|
|
||||||
return redirect(url_for("view_item", iid=iid))
|
return redirect(url_for("view_item", iid=iid))
|
||||||
|
else:
|
||||||
|
flash("Cannot save item", "danger")
|
||||||
|
|
||||||
return render_template("add.html", item=item)
|
return render_template("add.html", item=item)
|
||||||
|
|
||||||
@ -130,19 +141,9 @@ def remove_item():
|
|||||||
|
|
||||||
abort(404)
|
abort(404)
|
||||||
|
|
||||||
# iid = request.args.get("item")
|
|
||||||
# item = load_item(iid)
|
|
||||||
# if not item:
|
|
||||||
# abort(404)
|
|
||||||
# if request.method == "GET":
|
|
||||||
# return render_template("remove.html", item=item)
|
|
||||||
# elif request.method == "POST":
|
|
||||||
# delete_item(item)
|
|
||||||
# return redirect(url_for("index"))
|
|
||||||
|
|
||||||
@app.route("/api/preview")
|
@app.route("/api/preview")
|
||||||
@login_required
|
@login_required
|
||||||
def preview():
|
def link_preview():
|
||||||
return fetch_url_data(request.args.get("url"))
|
return fetch_url_data(request.args.get("url"))
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
@ -164,8 +165,7 @@ def login():
|
|||||||
if pass_equals or hash_equals:
|
if pass_equals or hash_equals:
|
||||||
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")
|
||||||
with open(user.filepath, "w") as f:
|
write_textual(user.filepath, write_metadata(user.data))
|
||||||
f.write(write_metadata(user.data))
|
|
||||||
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,8 +176,8 @@ def login():
|
|||||||
return render_template("login.html", form=form)
|
return render_template("login.html", form=form)
|
||||||
|
|
||||||
@app.route("/logout")
|
@app.route("/logout")
|
||||||
@login_required
|
|
||||||
def logout():
|
def logout():
|
||||||
|
if current_user.is_authenticated:
|
||||||
logout_user()
|
logout_user()
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
@ -190,32 +190,13 @@ def load_user(username:str):
|
|||||||
def walk_items():
|
def walk_items():
|
||||||
results, iids = {}, {}
|
results, iids = {}, {}
|
||||||
|
|
||||||
for root, dirs, files in os.walk(MEDIA_ROOT):
|
for root, dirs, files in os.walk(ITEMS_ROOT):
|
||||||
rel_path = os.path.relpath(root, MEDIA_ROOT).replace(os.sep, "/")
|
rel_path = os.path.relpath(root, ITEMS_ROOT).replace(os.sep, "/")
|
||||||
if rel_path == ".":
|
if rel_path == ".":
|
||||||
rel_path = ""
|
rel_path = ""
|
||||||
|
|
||||||
results[rel_path], iids[rel_path] = [], []
|
results[rel_path], iids[rel_path] = [], []
|
||||||
|
|
||||||
# for file in files:
|
|
||||||
# if file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])):
|
|
||||||
# iid = strip_ext(os.path.join(rel_path, file).replace(os.sep, "/"))
|
|
||||||
# image = os.path.join(rel_path, file).replace(os.sep, "/")
|
|
||||||
# data = load_sider_metadata(image) or {}
|
|
||||||
# data["image"] = image
|
|
||||||
# data["id"] = iid
|
|
||||||
# results[rel_path].append(data)
|
|
||||||
# files.remove(file)
|
|
||||||
|
|
||||||
# for file in files:
|
|
||||||
# if file.lower().endswith(ITEMS_EXT):
|
|
||||||
# iid = strip_ext(os.path.join(rel_path, file).replace(os.sep, "/"))
|
|
||||||
# with open(os.path.join(MEDIA_ROOT, rel_path, file), "r") as f:
|
|
||||||
# data = read_metadata(f.read())
|
|
||||||
# data["id"] = iid
|
|
||||||
# results[rel_path].append(data)
|
|
||||||
# files.remove(file)
|
|
||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
#if file.lower().endswith(ITEMS_EXT) or file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])):
|
#if file.lower().endswith(ITEMS_EXT) or file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["images"]])):
|
||||||
iid = strip_ext(os.path.join(rel_path, file).replace(os.sep, "/"))
|
iid = strip_ext(os.path.join(rel_path, file).replace(os.sep, "/"))
|
||||||
@ -229,14 +210,14 @@ def walk_items():
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def walk_collections(username:str=None):
|
def walk_collections(username:str):
|
||||||
results = {"": []}
|
results: dict[str, list[str]] = {"": []}
|
||||||
|
|
||||||
filepath = USERS_ROOT
|
filepath = USERS_ROOT
|
||||||
|
|
||||||
if username:
|
# if username:
|
||||||
filepath = os.path.join(filepath, username)
|
filepath = os.path.join(filepath, username)
|
||||||
results[""] = read_metadata(read_textual(filepath + ITEMS_EXT))["items"].strip().replace(" ", "\n").splitlines()
|
data = read_metadata(read_textual(filepath + ITEMS_EXT))
|
||||||
|
results[""] = data["items"] if "items" in data else []
|
||||||
|
|
||||||
# for root, dirs, files in os.walk(filepath):
|
# for root, dirs, files in os.walk(filepath):
|
||||||
# rel_path = os.path.relpath(root, filepath).replace(os.sep, "/")
|
# rel_path = os.path.relpath(root, filepath).replace(os.sep, "/")
|
||||||
@ -266,7 +247,7 @@ def filename_to_iid(iid:str):
|
|||||||
def load_item(iid:str):
|
def load_item(iid:str):
|
||||||
iid = filename_to_iid(iid)
|
iid = filename_to_iid(iid)
|
||||||
filename = iid_to_filename(iid)
|
filename = iid_to_filename(iid)
|
||||||
filepath = os.path.join(MEDIA_ROOT, filename)
|
filepath = os.path.join(ITEMS_ROOT, filename)
|
||||||
files = glob(f"{filepath}.*")
|
files = glob(f"{filepath}.*")
|
||||||
|
|
||||||
if len(files):
|
if len(files):
|
||||||
@ -274,89 +255,21 @@ def load_item(iid:str):
|
|||||||
|
|
||||||
for file in files:
|
for file in files:
|
||||||
if file.lower().endswith(ITEMS_EXT):
|
if file.lower().endswith(ITEMS_EXT):
|
||||||
# with open(file, "r", encoding="utf-8") as f:
|
|
||||||
# data = data | read_metadata(f.read())
|
|
||||||
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["images"]])):
|
||||||
data["image"] = file.replace(os.sep, "/").removeprefix(f"{MEDIA_ROOT}/")
|
data["image"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/")
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
def load_sider_metadata(filename:str):
|
def store_item(iid:str, data:dict, files:dict):
|
||||||
filepath = os.path.join(MEDIA_ROOT, f"{strip_ext(filename)}{ITEMS_EXT}")
|
iid = filename_to_iid(iid)
|
||||||
if os.path.exists(filepath):
|
existing = load_item(iid)
|
||||||
with open(filepath, "r") as f:
|
filename = split_iid(iid_to_filename(iid))
|
||||||
return read_metadata(f.read())
|
filepath = os.path.join(ITEMS_ROOT, *filename)
|
||||||
|
mkdirs(os.path.join(ITEMS_ROOT, filename[0]))
|
||||||
# def read_metadata(text:str):
|
|
||||||
# data = {}
|
|
||||||
# xml = "<root>" + re.sub(r'<(\w+)>(.*?)</>', r'<\1>\2</\1>', text) + "</root>"
|
|
||||||
# for elem in ElementTree.fromstring(xml, parser=ElementTree.XMLParser(encoding="utf-8")).findall('*'):
|
|
||||||
# data[elem.tag] = elem.text.strip()
|
|
||||||
# return data
|
|
||||||
|
|
||||||
def read_metadata(text:str) -> dict:
|
|
||||||
config = configparser.ConfigParser(allow_unnamed_section=True, interpolation=None)
|
|
||||||
config.read_string(text)
|
|
||||||
return config._sections[configparser.UNNAMED_SECTION] # tuple(config._sections.values())[0]
|
|
||||||
|
|
||||||
# def write_metadata(data:dict):
|
|
||||||
# text = ""
|
|
||||||
# for key in data:
|
|
||||||
# if key not in ("image",) and (value := data[key]):
|
|
||||||
# text += f'<{key}>{value}</>\n'
|
|
||||||
# return text
|
|
||||||
|
|
||||||
def write_metadata(data:dict) -> str:
|
|
||||||
output = StringIO()
|
|
||||||
config = configparser.ConfigParser(allow_unnamed_section=True, interpolation=None)
|
|
||||||
del data["image"]
|
|
||||||
config[configparser.UNNAMED_SECTION] = data
|
|
||||||
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:bytes):
|
|
||||||
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")
|
|
||||||
|
|
||||||
description = None
|
|
||||||
desc_tag = soup.find("meta", attrs={"name": "description"}) or \
|
|
||||||
soup.find("meta", attrs={"property": "og:description"})
|
|
||||||
if desc_tag and "content" in desc_tag.attrs:
|
|
||||||
description = desc_tag["content"]
|
|
||||||
|
|
||||||
image = None
|
|
||||||
img_tag = soup.find("meta", attrs={"property": "og:image"}) or \
|
|
||||||
soup.find("meta", attrs={"name": "twitter:image"})
|
|
||||||
if img_tag and "content" in img_tag.attrs:
|
|
||||||
image = img_tag["content"]
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": soup_or_default(soup, "meta", {"property": "og:title"}, "content", (soup.title.string if soup.title else None)),
|
|
||||||
"description": description,
|
|
||||||
"image": image,
|
|
||||||
"link": soup_or_default(soup, "link", {"rel": "canonical"}, "href", url),
|
|
||||||
}
|
|
||||||
|
|
||||||
def store_item(iid, data, files):
|
|
||||||
iid = iid_to_filename(iid)
|
|
||||||
iid = split_iid(strip_ext(iid))
|
|
||||||
filepath = os.path.join(MEDIA_ROOT, *iid)
|
|
||||||
Path(os.path.join(MEDIA_ROOT, iid[0])).mkdir(parents=True, exist_ok=True)
|
|
||||||
image = False
|
image = False
|
||||||
|
|
||||||
if len(files):
|
if len(files):
|
||||||
file = files["file"]
|
file = files["file"]
|
||||||
if file.seek(0, os.SEEK_END):
|
if file.seek(0, os.SEEK_END):
|
||||||
@ -369,39 +282,117 @@ def store_item(iid, data, files):
|
|||||||
ext = response.headers["Content-Type"].split("/")[1]
|
ext = response.headers["Content-Type"].split("/")[1]
|
||||||
with open(f"{filepath}.{ext}", "wb") as f:
|
with open(f"{filepath}.{ext}", "wb") as f:
|
||||||
f.write(response.content)
|
f.write(response.content)
|
||||||
# with open(filepath + ITEMS_EXT, "w", encoding="utf-8") as f:
|
image = True
|
||||||
# f.write(write_metadata(data))
|
if not (existing or image or data["text"]):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if "creator" in existing:
|
||||||
|
data["creator"] = existing["creator"]
|
||||||
|
else:
|
||||||
|
data["creator"] = current_user.username
|
||||||
|
items = current_user.data["items"] if "items" in current_user.data else []
|
||||||
|
items.append(iid)
|
||||||
|
current_user.data["items"] = items
|
||||||
|
write_textual(current_user.filepath, write_metadata(current_user.data))
|
||||||
|
|
||||||
write_textual(filepath + ITEMS_EXT, write_metadata(data))
|
write_textual(filepath + ITEMS_EXT, write_metadata(data))
|
||||||
|
return True
|
||||||
|
|
||||||
def delete_item(item:dict):
|
def delete_item(item:dict):
|
||||||
filepath = os.path.join(MEDIA_ROOT, iid_to_filename(item["id"]))
|
filepath = os.path.join(ITEMS_ROOT, iid_to_filename(item["id"]))
|
||||||
files = glob(f"{filepath}.*")
|
files = glob(f"{filepath}.*")
|
||||||
# for key in ("id", "image"):
|
|
||||||
# if key in item and (value := item[key]):
|
|
||||||
# filepath = os.path.join(MEDIA_ROOT, value)
|
|
||||||
# if os.path.exists(filepath):
|
|
||||||
# os.remove(filepath)
|
|
||||||
for file in files:
|
for file in files:
|
||||||
os.remove(file)
|
os.remove(file)
|
||||||
|
|
||||||
def prop_or_default(items:dict, prop:str, default):
|
def read_metadata(text:str) -> dict:
|
||||||
|
config = ConfigParser(interpolation=None)
|
||||||
|
config.read_string(f"[DEFAULT]\n{text}")
|
||||||
|
data = config._defaults # type: ignore[attr-defined]
|
||||||
|
for key in ("items",):
|
||||||
|
if key in data:
|
||||||
|
data[key] = wsv_to_list(data[key])
|
||||||
|
return data
|
||||||
|
|
||||||
|
def write_metadata(data:dict) -> str:
|
||||||
|
output = StringIO()
|
||||||
|
config = ConfigParser(interpolation=None)
|
||||||
|
for key in ("image", "datetime"):
|
||||||
|
if key in data:
|
||||||
|
del data[key]
|
||||||
|
for key in data:
|
||||||
|
if type(data[key]) == list:
|
||||||
|
data[key] = list_to_wsv(data[key])
|
||||||
|
config["DEFAULT"] = data
|
||||||
|
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")
|
||||||
|
|
||||||
|
description = None
|
||||||
|
desc_tag = soup.find("meta", attrs={"name": "description"}) or \
|
||||||
|
soup.find("meta", attrs={"property": "og:description"})
|
||||||
|
if desc_tag and "content" in desc_tag.attrs: # type: ignore[attr-defined]
|
||||||
|
description = desc_tag["content"] # type: ignore[index]
|
||||||
|
|
||||||
|
image = None
|
||||||
|
img_tag = soup.find("meta", attrs={"property": "og:image"}) or \
|
||||||
|
soup.find("meta", attrs={"name": "twitter:image"})
|
||||||
|
if img_tag and "content" in img_tag.attrs: # type: ignore[attr-defined]
|
||||||
|
image = img_tag["content"] # type: ignore[index]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"title": soup_or_default(soup, "meta", {"property": "og:title"}, "content", (soup.title.string if soup.title else None)),
|
||||||
|
"description": description,
|
||||||
|
"image": image,
|
||||||
|
"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
|
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)
|
return prop_or_default(soup.find(tag, attrs=attrs), prop, default)
|
||||||
|
|
||||||
def generate_iid():
|
def generate_iid() -> str:
|
||||||
return str(next(snowflake))
|
return str(next(snowflake))
|
||||||
# iid = next(snowflake)
|
|
||||||
# date = Snowflake.parse(iid, snowflake_epoch).datetime
|
|
||||||
# return f"{date.year}/{date.month}/{next(snowflake)}"
|
|
||||||
|
|
||||||
def split_iid(iid:str):
|
def split_iid(iid:str):
|
||||||
iid = iid.split("/")
|
toks = iid.split("/")
|
||||||
return ["/".join(iid[:-1]), iid[-1]]
|
return ["/".join(toks[:-1]), toks[-1]]
|
||||||
|
|
||||||
def strip_ext(filename:str):
|
def strip_ext(filename:str):
|
||||||
return os.path.splitext(filename)[0]
|
return os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
def list_to_wsv(data:list, sep="\n") -> str:
|
||||||
|
return sep.join(data)
|
||||||
|
|
||||||
|
def wsv_to_list(data:str) -> list:
|
||||||
|
return data.strip().replace(" ", "\n").replace("\t", "\n").splitlines()
|
||||||
|
|
||||||
|
def mkdirs(*paths:str):
|
||||||
|
for path in paths:
|
||||||
|
Path(path).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
mkdirs(ITEMS_ROOT, USERS_ROOT)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(debug=True)
|
if DEVELOPMENT:
|
||||||
|
app.run(port=HTTP_PORT, debug=True)
|
||||||
|
else:
|
||||||
|
import waitress
|
||||||
|
waitress.serve(app, port=HTTP_PORT, threads=HTTP_THREADS)
|
||||||
|
18
package-lock.json
generated
Normal file
18
package-lock.json
generated
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Pignio",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"dependencies": {
|
||||||
|
"uikit": "^3.23.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/uikit": {
|
||||||
|
"version": "3.23.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/uikit/-/uikit-3.23.11.tgz",
|
||||||
|
"integrity": "sha512-srUFBf5DfUxVpodcygibMQt1vgQjR9wlhIQo4GeWVpugk5+mKLPASJITDoY8wcwXQIHm7koELiPJ+FgNbzLv0A==",
|
||||||
|
"license": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
5
package.json
Normal file
5
package.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"uikit": "^3.23.11"
|
||||||
|
}
|
||||||
|
}
|
@ -2,5 +2,7 @@ flask
|
|||||||
flask-bcrypt
|
flask-bcrypt
|
||||||
flask-login
|
flask-login
|
||||||
flask-wtf
|
flask-wtf
|
||||||
|
beautifulsoup4
|
||||||
requests
|
requests
|
||||||
snowflake-id
|
snowflake-id
|
||||||
|
waitress
|
@ -19,35 +19,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" class="uk-checkbox" checked />
|
<input type="checkbox" class="uk-checkbox" {% if not item.id %} checked {% endif %} />
|
||||||
Fill Data from Link
|
Fill data from link
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% if not item.id %}
|
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<div uk-form-custom>
|
<div uk-grid>
|
||||||
<input class="uk-input" type="file" name="file" aria-label="Custom controls" />
|
<div uk-form-custom class="uk-width-1-1">
|
||||||
<button class="uk-button uk-button-default" type="button" tabindex="-1">Select file</button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
</div>
|
||||||
<div class="uk-margin">
|
<!-- <div class="uk-margin">
|
||||||
<input type="color" class="uk-input" name="color" value="{{ item.text }}" />
|
<input type="color" class="uk-input" name="color" value="{{ item.text }}" />
|
||||||
</div>
|
</div> -->
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<input class="uk-input" type="text" name="link" placeholder="URL..." value="{{ item.link }}" />
|
<input class="uk-input" type="text" name="link" placeholder="URL" value="{{ item.link }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<input class="uk-input" type="text" name="title" placeholder="Title" value="{{ item.title }}" />
|
<input class="uk-input" type="text" name="title" placeholder="Title" value="{{ item.title }}" />
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<textarea class="uk-textarea" name="description" placeholder="Description">{{ item.description }}</textarea>
|
<textarea class="uk-textarea" rows="5" name="description" placeholder="Description">{{ item.description }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<textarea class="uk-textarea" name="text" placeholder="Text">{{ item.text }}</textarea>
|
<textarea class="uk-textarea" rows="5" name="text" placeholder="Text">{{ item.text }}</textarea>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<button class="uk-input uk-button uk-button-default" 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>
|
||||||
@ -55,8 +55,9 @@
|
|||||||
var check = document.querySelector('form input[type="checkbox"]');
|
var check = document.querySelector('form input[type="checkbox"]');
|
||||||
['change', 'input', 'paste'].forEach(handler => {
|
['change', 'input', 'paste'].forEach(handler => {
|
||||||
link.addEventListener(handler, () => {
|
link.addEventListener(handler, () => {
|
||||||
if (check.checked) {
|
var url = link.value.trim();
|
||||||
fetch('http://localhost:5000/api/preview?url=' + encodeURIComponent(link.value))
|
if (check.checked && url) {
|
||||||
|
fetch('{{ url_for("link_preview") }}?url=' + encodeURIComponent(url))
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
for (var key in data) {
|
for (var key in data) {
|
||||||
|
@ -1,27 +1,36 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
{% set title %}{% block title %}{% endblock %}{% endset %}
|
||||||
|
{% set canonical %}{% block canonical %}{% endblock %}{% endset %}
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>{% block title %}My Media Gallery{% endblock %}</title>
|
<title>{% if title %}{{ title }} | {% endif %}{{ config.APP_ICON }} {{ config.APP_NAME }}</title>
|
||||||
<link rel="canonical" href="{% block canonical %}{% endblock %}" />
|
<link rel="stylesheet" href="{{ url_for('serve_module', module='uikit', filename='css/uikit.min.css') }}" />
|
||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/css/uikit.min.css" />
|
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit.min.js') }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/js/uikit.min.js"></script>
|
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/uikit@3.23.11/dist/js/uikit-icons.min.js"></script>
|
{% if canonical %}
|
||||||
|
<link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" />
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<nav class="uk-navbar-container" uk-navbar>
|
<nav class="uk-navbar-container" uk-sticky="sel-target: .uk-navbar-container; cls-active: uk-navbar-sticky">
|
||||||
<div class="uk-navbar-left">
|
<div class="uk-container uk-container-expand" uk-navbar>
|
||||||
<a class="uk-button uk-button-primary" href="{{ url_for('index') }}">🏠 Home</a>
|
<div class="uk-navbar-left uk-width-expand">
|
||||||
</div>
|
<a class="uk-logo" href="{{ url_for('index') }}">
|
||||||
<div class="uk-navbar-center">
|
<span uk-icon="icon: home"></span>
|
||||||
<form class="uk-search uk-search-default" action="{{ url_for('search') }}">
|
{{ config.APP_NAME }}
|
||||||
<input class="uk-search-input" type="search" name="query" placeholder="Search or Add..." value="{{ query }}" required>
|
</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>
|
||||||
<button class="uk-search-icon-flip" uk-search-icon></button>
|
<button class="uk-search-icon-flip" uk-search-icon></button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-navbar-right">
|
<div class="uk-navbar-right uk-margin-left">
|
||||||
<a class="uk-button uk-button-default" href="{{ url_for('add_item') }}">🆕 Add</a>
|
<a class="uk-button uk-button-default uk-icon-link" uk-icon="plus" href="{{ url_for('add_item') }}">
|
||||||
|
<span class="uk-visible@s">Create</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="uk-container uk-margin">
|
<div class="uk-container uk-margin">
|
||||||
@ -32,8 +41,6 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
|
||||||
<div class="uk-container uk-margin">
|
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}📁 Media Index{% endblock %}
|
{% block canonical %}{{ url_for('index') }}{% endblock %}
|
||||||
<!-- {% block canonical %}{{ url_for('index') }}{% endblock %} -->
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% include "results.html" %}
|
{% include "results.html" %}
|
||||||
{% endblock %}
|
{% endblock %}
|
6
templates/item-card.html
Normal file
6
templates/item-card.html
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<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>
|
7
templates/item-content.html
Normal file
7
templates/item-content.html
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{% if item.text %}
|
||||||
|
<div class="uk-background-cover uk-height-small uk-flex uk-flex-center uk-flex-middle uk-text-center uk-text-middle uk-overflow-hidden" style="background-image: url('https://getuikit.com/docs/images/dark.jpg');">
|
||||||
|
<p class="uk-text-stroke uk-text-primary">{{ item.text }}</p>
|
||||||
|
</div>
|
||||||
|
{% elif item.image %}
|
||||||
|
<img class="uk-width-expand" src="{{ url_for('serve_media', filename=item.image) }}" alt="{{ item.description }}" />
|
||||||
|
{% endif %}
|
@ -1,28 +1,33 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}📌 Viewing {% 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 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">
|
||||||
{% if item.image %}
|
{% include "item-content.html" %}
|
||||||
<img src="{{ url_for('serve_media', filename=item.image) }}" />
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<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">
|
||||||
<h3>{% include "item-title.html" %}</h3>
|
<h3>{% include "item-title.html" %}</h3>
|
||||||
|
by <a href="{{ url_for('view_user', username=item.creator) }}">{{ item.creator }}</a>
|
||||||
|
<!-- at <span>{{ item.datetime }}</span> -->
|
||||||
<div class="uk-text-truncate">
|
<div class="uk-text-truncate">
|
||||||
<a href="{{ item.link }}" target="_blank">{{ item.link }}</a>
|
<a href="{{ item.link }}" target="_blank">{{ item.link }}</a>
|
||||||
</div>
|
</div>
|
||||||
<span>{{ item.created }}</span>
|
|
||||||
<p>{{ item.description }}</p>
|
<p>{{ item.description }}</p>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
<a class="uk-button uk-button-default" href="{{ url_for('add_item') }}?item={{ item.id }}">Edit</a>
|
<a class="uk-button uk-button-default uk-icon-link" href="{{ url_for('add_item') }}?item={{ item.id }}">
|
||||||
<a class="uk-button uk-button-danger" href="{{ url_for('remove_item') }}?item={{ item.id }}">Remove</a>
|
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
|
||||||
|
<span uk-icon="icon: trash"></span>
|
||||||
|
</a>
|
||||||
<!-- <form class="uk-inline" method="POST" action="{{ url_for('remove_item') }}">
|
<!-- <form class="uk-inline" method="POST" action="{{ url_for('remove_item') }}">
|
||||||
<input type="hidden" name="item" value="{{ item.id }}" />
|
<input type="hidden" name="item" value="{{ item.id }}" />
|
||||||
<button class="uk-input uk-button uk-button-danger" type="submit">Remove</button>
|
<button class="uk-input uk-button uk-button-danger" type="submit">Remove</button>
|
||||||
</form> -->
|
</form> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -4,13 +4,22 @@
|
|||||||
<form method="POST">
|
<form method="POST">
|
||||||
{{ form.hidden_tag() }}
|
{{ form.hidden_tag() }}
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1">
|
||||||
|
<span class="uk-form-icon" uk-icon="icon: user"></span>
|
||||||
{{ form.username(class_="uk-input", placeholder="Username") }}
|
{{ form.username(class_="uk-input", placeholder="Username") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
|
||||||
{{ form.password(class_="uk-input", placeholder="Password") }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="uk-margin">
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1">
|
||||||
|
<span class="uk-form-icon" uk-icon="icon: lock"></span>
|
||||||
|
{{ form.password(class_="uk-input", placeholder="Password") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="uk-margin">
|
||||||
|
<div class="uk-inline uk-width-1-1">
|
||||||
{{ form.submit(class_="uk-input uk-button uk-button-default") }}
|
{{ form.submit(class_="uk-input uk-button uk-button-default") }}
|
||||||
|
<span class="uk-form-icon uk-form-icon-flip" uk-icon="icon: lock"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -3,8 +3,13 @@
|
|||||||
{% 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 }}" />
|
||||||
<div class="uk-margin">
|
<div class="uk-margin uk-grid-small" uk-grid>
|
||||||
|
<div>
|
||||||
|
<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">Remove {{ item.id }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
@ -1,18 +1,7 @@
|
|||||||
<div uk-grid="masonry: pack" class="uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl">
|
<div uk-grid="masonry: pack" class="uk-grid-small uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl">
|
||||||
{% for folder, items in media.items() %}
|
{% for folder, items in items.items() %}
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<div margin="uk-grid-margin-small">
|
{% include "item-card.html" %}
|
||||||
<a class="uk-link-text" href="{{ url_for('view_item', iid=item.id) }}">
|
|
||||||
{% if item.image %}
|
|
||||||
<img src="{{ url_for('serve_media', filename=item.image) }}" />
|
|
||||||
{% elif item.description %}
|
|
||||||
<div class="uk-background-cover uk-height-small uk-flex uk-flex-center uk-flex-middle uk-text-center uk-text-middle uk-overflow-hidden" style="background-image: url('https://getuikit.com/docs/images/dark.jpg');">
|
|
||||||
<p class="uk-text-stroke uk-text-primary">{{ item.description }}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
<span class="uk-text-break">{% include "item-title.html" %}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
@ -1,7 +1,7 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}Search Results for "{{ query }}"{% endblock %}
|
{% block title %}Search Results for "{{ query }}"{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if media_data %}
|
{% if items %}
|
||||||
<p>🔍 Results for "{{ query }}"</p>
|
<p>🔍 Results for "{{ query }}"</p>
|
||||||
{% include "results.html" %}
|
{% include "results.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
|
@ -1,11 +1,25 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
{% block title %}User {{ user.username }}{% endblock %}
|
{% block title %}User {{ user.username }}{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{{ user.username }}
|
<div class="uk-text-center">
|
||||||
<img src="{{ url_for('serve_media', filename=load_item(user.data.propic).image) }}" />
|
<h2>{{ user.username }}</h2>
|
||||||
|
{% if user.data.propic %}
|
||||||
|
{% with item=load_item(user.data.propic) %}
|
||||||
|
<div class="uk-width-medium uk-align-center">
|
||||||
|
{% include "item-content.html" %}
|
||||||
|
</div>
|
||||||
|
{% endwith %}
|
||||||
|
{% endif %}
|
||||||
|
<p>{{ user.data.description }}</p>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div uk-grid="masonry: pack" class="uk-grid-small uk-child-width-1-2 uk-child-width-1-3@s uk-child-width-1-4@m uk-child-width-1-5@l uk-child-width-1-6@xl">
|
||||||
{% for folder, collection in collections.items() %}
|
{% for folder, collection in collections.items() %}
|
||||||
{% for item in collection %}
|
{% for iid in collection %}
|
||||||
{{ item }}
|
{% with item=load_item(iid) %}
|
||||||
|
{% include "item-card.html" %}
|
||||||
|
{% endwith %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
Reference in New Issue
Block a user