AJAX loading of links, forms and home page items with Unpoly; Fix bad horizontal overflow introduced by flex; Initial support for videos; Initial support of systags and storing media provenance; More efficient and safe metadata storing (only non-void values are saved)

This commit is contained in:
2025-07-17 02:39:54 +02:00
parent 3b1dcec778
commit 34c7492ce9
10 changed files with 160 additions and 91 deletions

2
.gitignore vendored
View File

@ -1,5 +1,5 @@
/_config.py
/data/
/node_modules/
/.mypy_cache/
.mypy_cache/
*.pyc

57
app.py
View File

@ -91,7 +91,11 @@ def noindex(view_func):
@app.route("/")
def index():
return render_template("index.html", items=walk_items())
limit = 50
page = int(request.args.get("page", 1))
next_count = (limit * page)
items = walk_items()
return render_template("index.html", items=items[(limit * (page - 1)):next_count], next_page=(page + 1 if len(items) > next_count else None))
@app.route("/manifest.json")
@noindex
@ -102,7 +106,12 @@ def serve_manifest():
@app.route("/static/module/<path:module>/<path:filename>")
@noindex
def serve_module(module:str, filename:str):
def serve_module_main(module:str, filename:str):
return send_from_directory(os.path.join("node_modules", module), filename)
@app.route("/static/module/dist/<path:module>/<path:filename>")
@noindex
def serve_module_dist(module:str, filename:str):
return send_from_directory(os.path.join("node_modules", module, "dist"), filename)
@app.route("/media/<path:filename>")
@ -127,15 +136,17 @@ def view_user(username:str):
def search():
query = request.args.get("query", "").lower()
found = False
results = {}
results = [] # {}
for folder, items in walk_items().items():
results[folder] = []
for item in walk_items():
# for folder, items in walk_items().items():
# results[folder] = []
for item in items:
if any([query in text.lower() for text in item.values()]):
results[folder].append(item)
found = True
#for item in items:
if any([query in text.lower() for text in item.values()]):
# results[folder].append(item)
results.append(item)
found = True
return render_template("search.html", items=(results if found else None), query=query)
@ -282,7 +293,7 @@ def walk_items():
data = load_item(iid)
results[rel_path].append(data)
return results
return [value for values in results.values() for value in values] # results
def walk_collections(username:str):
results: dict[str, list[str]] = {"": []}
@ -330,9 +341,10 @@ def load_item(iid:str):
for file in files:
if file.lower().endswith(ITEMS_EXT):
data = data | read_metadata(read_textual(file))
elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["image"]])):
data["image"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/")
elif file.lower().endswith(tuple([f".{ext}" for ext in EXTENSIONS["video"]])):
data["video"] = file.replace(os.sep, "/").removeprefix(f"{ITEMS_ROOT}/")
return data
@ -343,6 +355,7 @@ def store_item(iid:str, data:dict, files:dict|None=None):
filepath = os.path.join(ITEMS_ROOT, *filename)
mkdirs(os.path.join(ITEMS_ROOT, filename[0]))
image = False
extra = {key: data[key] for key in ["provenance"] if key in data}
data = {key: data[key] for key in ["link", "title", "description", "image", "text"] if key in data}
if files and len(files):
@ -351,7 +364,7 @@ def store_item(iid:str, data:dict, files:dict|None=None):
file.seek(0, os.SEEK_SET)
mime = file.content_type.split("/")
ext = mime[1]
if mime[0] == "image" and ext in EXTENSIONS["image"]:
if mime[0] == "image" and ext in EXTENSIONS["image"] or mime[0] == "video" and ext in EXTENSIONS["video"]:
file.save(f"{filepath}.{ext}")
image = True
if not image and "image" in data and data["image"]:
@ -373,8 +386,8 @@ def store_item(iid:str, data:dict, files:dict|None=None):
return False
if existing:
if "creator" in existing:
data["creator"] = existing["creator"]
if (creator := safe_str_get(existing, "creator")):
data["creator"] = creator
else:
data["creator"] = current_user.username
items = current_user.data["items"] if "items" in current_user.data else []
@ -382,6 +395,8 @@ def store_item(iid:str, data:dict, files:dict|None=None):
current_user.data["items"] = items
write_textual(current_user.filepath, write_metadata(current_user.data))
if (provenance := safe_str_get(extra, "provenance")):
data["systags"] = provenance
write_textual(filepath + ITEMS_EXT, write_metadata(data))
return True
@ -404,12 +419,15 @@ def read_metadata(text:str) -> dict:
def write_metadata(data:dict) -> str:
output = StringIO()
config = ConfigParser(interpolation=None)
for key in ("image", "datetime"):
for key in ("image", "video", "datetime"):
if key in data:
del data[key]
for key in data:
if type(data[key]) == list:
data[key] = list_to_wsv(data[key])
for key in list(data.keys()):
if (value := data[key]):
if type(value) == list:
data[key] = list_to_wsv(value)
else:
del data[key]
config["DEFAULT"] = data
config.write(output)
return "\n".join(output.getvalue().splitlines()[1:]) # remove section header
@ -457,6 +475,9 @@ def list_to_wsv(data:list, sep="\n") -> str:
def wsv_to_list(data:str) -> list:
return data.strip().replace(" ", "\n").replace("\t", "\n").splitlines()
def safe_str_get(dikt:dict[str, str|None], key:str) -> str:
return dikt.get(key) or ""
def mkdirs(*paths:str):
for path in paths:
Path(path).mkdir(parents=True, exist_ok=True)

9
package-lock.json generated
View File

@ -5,7 +5,8 @@
"packages": {
"": {
"dependencies": {
"uikit": "^3.23.11"
"uikit": "^3.23.11",
"unpoly": "^3.11.0"
}
},
"node_modules/uikit": {
@ -13,6 +14,12 @@
"resolved": "https://registry.npmjs.org/uikit/-/uikit-3.23.11.tgz",
"integrity": "sha512-srUFBf5DfUxVpodcygibMQt1vgQjR9wlhIQo4GeWVpugk5+mKLPASJITDoY8wcwXQIHm7koELiPJ+FgNbzLv0A==",
"license": "MIT"
},
"node_modules/unpoly": {
"version": "3.11.0",
"resolved": "https://registry.npmjs.org/unpoly/-/unpoly-3.11.0.tgz",
"integrity": "sha512-9ozLaNvg17XAUd9c+gMCfDqZePDeb1fGpqQHAuQDMRF/Vf8X94rdrgo6A5oqt4PlxcywoJ8QGH4yS6J0vFZxhg==",
"license": "MIT"
}
}
}

View File

@ -1,5 +1,6 @@
{
"dependencies": {
"uikit": "^3.23.11"
"uikit": "^3.23.11",
"unpoly": "^3.11.0"
}
}

View File

@ -1,57 +1,69 @@
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;
}
if ('up' in window) {
up.compiler('form.add', addHandler);
} else {
var form = document.querySelector('form.add');
if (form) {
addHandler(form);
}
});
}
['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;
}
}
})
function addHandler(form) {
var link = form.querySelector('input[name="link"]');
var checkLink = form.querySelector('input.from-link');
// var checkProxatore = form.querySelector('input.with-proxatore');
var image = form.querySelector('img.image');
var upload = form.querySelector('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 (checkLink.checked && url) {
fetch('../api/preview?url=' + encodeURIComponent(url))
.then(res => res.json())
.then(data => {
for (var key in data) {
var field = form.querySelector(`[name="${key}"]`);
if (field) {
field.value = data[key];
}
var el = form.querySelector(`[class="${key}"]`);
if (el) {
el.src = data[key];
el.parentElement.hidden = false;
}
}
})
}
})
});
}

View File

@ -11,23 +11,34 @@
<div>b</div>
</div>
-->
<form method="POST" enctype="multipart/form-data">
<form method="POST" enctype="multipart/form-data" class="add">
<input type="hidden" name="id" value="{{ item.id }}" />
<input type="hidden" name="image" />
<div class="uk-margin" {% if not item.image %} hidden {% endif %}>
<img src="{{ url_for('serve_media', filename=item.image) }}" class="image" />
</div>
<div class="uk-margin">
<div class="uk-margin uk-grid">
<label>
<input type="checkbox" class="uk-checkbox" {% if not item.id %} checked {% endif %} />
<input type="checkbox" class="from-link uk-checkbox" {% if not item.id %} checked {% endif %} />
Fill data from link
</label>
<label>
<select class="uk-select" name="provenance">
<option value="">No provenance</option>
<option value="oc" {% if item.systags == 'oc' %} selected {% endif %}>Original content</option>
<option value="ai" {% if item.systags == 'ai' %} selected {% endif %}>AI-generated</option>
</select>
</label>
<!-- <label>
<input type="checkbox" class="with-proxatore uk-checkbox" />
Transparently use Proxatore
</label> -->
</div>
<div class="uk-margin">
<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 or drop an image file</button>
<button class="uk-button uk-width-1-1 uk-button-default" type="button" tabindex="-1">Select or drop a media file</button>
</div>
</div>
</div>
@ -50,5 +61,4 @@
<button class="uk-input uk-button uk-button-primary" type="submit">{{ ("Edit " + item.id) if item.id else "Add" }}</button>
</div>
</form>
<script src="{{ url_for('static', filename='add.js') }}"></script>
{% endblock %}

View File

@ -6,9 +6,16 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{% if title %}{{ title }} | {% endif %}{{ config.APP_ICON }} {{ config.APP_NAME }}</title>
<link rel="stylesheet" href="{{ url_for('serve_module', module='uikit', filename='css/uikit.min.css') }}" />
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit.min.js') }}"></script>
<script src="{{ url_for('serve_module', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
<link rel="stylesheet" href="{{ url_for('serve_module_dist', module='uikit', filename='css/uikit.min.css') }}" />
<script src="{{ url_for('serve_module_dist', module='uikit', filename='js/uikit.min.js') }}"></script>
<script src="{{ url_for('serve_module_dist', module='uikit', filename='js/uikit-icons.min.js') }}"></script>
<script src="{{ url_for('serve_module_main', module='unpoly', filename='unpoly.min.js') }}" onload="(function(){
up.link.config.followSelectors.push('a[href]');
up.link.config.instantSelectors.push('a[href]');
up.form.config.submitSelectors.push(['form']);
})();"></script>
<link rel="stylesheet" href="{{ url_for('serve_module_main', module='unpoly', filename='unpoly.min.css') }}" />
<script src="{{ url_for('static', filename='add.js') }}" defer></script>
{% if canonical %}
<link rel="canonical" href="{{ config.LINKS_PREFIX }}{{ canonical }}" />
<meta name="og:url" content="{{ config.LINKS_PREFIX }}{{ canonical }}" />
@ -43,8 +50,8 @@
</div>
</div>
</nav>
<div class="uk-flex uk-flex-center uk-margin uk-flex-auto">
<div class="uk-container uk-flex-auto">
<div class="uk-margin uk-flex-auto">
<div class="uk-container">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}

View File

@ -4,4 +4,6 @@
</div>
{% elif item.image %}
<img class="uk-width-expand" src="{{ url_for('serve_media', filename=item.image) }}" alt="{{ item.description }}" />
{% elif item.video %}
<video src="{{ url_for('serve_media', filename=item.video) }}" {% if full %} controls {% else %} autoplay muted {% endif %} loop></video>
{% endif %}

View File

@ -11,12 +11,16 @@
<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" />
{% elif item.video %}
<meta name="og:video" content="{{ url_for('serve_media', filename=item.video) }}" />
{% 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">
{% include "item-content.html" %}
{% with full=true %}
{% include "item-content.html" %}
{% endwith %}
</div>
<div class="uk-width-1-1 uk-width-1-2@s uk-padding-small">
<h3>{% include "item-title.html" %}</h3>
@ -25,7 +29,7 @@
<div class="uk-text-truncate">
<a href="{{ item.link }}" target="_blank">{{ item.link }}</a>
</div>
<p>{{ item.description }}</p>
<p class="uk-text-break" style="white-space: pre-line;">{{ 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 }}" rel="nofollow">
Edit

View File

@ -1,7 +1,12 @@
<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 items.items() %}
<div uk-grid="masonry: pack" class="results 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 item in items %}
{% include "item-card.html" %}
{% endfor %}
{% endfor %}
</div>
</div>
{% if next_page %}
<div class="load-wrapper uk-margin">
<a href="/?page={{ next_page }}" class="uk-button uk-button-secondary uk-width-1-1"
up-target=".results:after, .load-wrapper" up-select=".results > *, .load-wrapper"
>Load More</a>
</div>
{% endif %}