diff --git a/app/blueprints/packages/packages.py b/app/blueprints/packages/packages.py index b4ad9ee..65a0b07 100644 --- a/app/blueprints/packages/packages.py +++ b/app/blueprints/packages/packages.py @@ -250,6 +250,7 @@ class PackageForm(FlaskForm): website = StringField(lazy_gettext("Website URL"), [Optional(), URL()], filters = [lambda x: x or None]) issueTracker = StringField(lazy_gettext("Issue Tracker URL"), [Optional(), URL()], filters = [lambda x: x or None]) forums = IntegerField(lazy_gettext("Forum Topic ID"), [Optional(), NumberRange(0,999999)]) + video_url = StringField(lazy_gettext("Video URL"), [Optional(), URL()], filters = [lambda x: x or None]) submit = SubmitField(lazy_gettext("Save")) @@ -333,6 +334,7 @@ def create_edit(author=None, name=None): "website": form.website.data, "issueTracker": form.issueTracker.data, "forums": form.forums.data, + "video_url": form.video_url.data, }) if wasNew and package.repo is not None: diff --git a/app/flatpages/help/api.md b/app/flatpages/help/api.md index d0cb351..679ac33 100644 --- a/app/flatpages/help/api.md +++ b/app/flatpages/help/api.md @@ -89,6 +89,7 @@ Tokens can be attained by visiting [Settings > API Tokens](/user/tokens/). * `website`: Website URL. * `issue_tracker`: Issue tracker URL. * `forums`: forum topic ID. + * `video_url`: URL to a video, YouTube only for now. * GET `/api/packages///dependencies/` * Returns dependencies, with suggested candidates * If query argument `only_hard` is present, only hard deps will be returned. diff --git a/app/flatpages/help/package_config.md b/app/flatpages/help/package_config.md index 486a075..68f84c6 100644 --- a/app/flatpages/help/package_config.md +++ b/app/flatpages/help/package_config.md @@ -61,6 +61,7 @@ It should be a JSON dictionary with one or more of the following optional keys: * `website`: Website URL. * `issue_tracker`: Issue tracker URL. * `forums`: forum topic ID. +* `video_url`: URL to a video, YouTube only for now. Use `null` to unset fields where relevant. diff --git a/app/logic/packages.py b/app/logic/packages.py index adff744..4f96724 100644 --- a/app/logic/packages.py +++ b/app/logic/packages.py @@ -23,6 +23,7 @@ from app.logic.LogicError import LogicError from app.models import User, Package, PackageType, MetaPackage, Tag, ContentWarning, db, Permission, AuditSeverity, \ License, UserRank, PackageDevState from app.utils import addAuditLog +from app.utils.url import clean_youtube_url def check(cond: bool, msg: str): @@ -61,6 +62,7 @@ ALLOWED_FIELDS = { "issue_tracker": str, "issueTracker": str, "forums": int, + "video_url": str, } ALIASES = { @@ -128,8 +130,13 @@ def do_edit_package(user: User, package: Package, was_new: bool, was_web: bool, if "media_license" in data: data["media_license"] = get_license(data["media_license"]) + if "video_url" in data: + data["video_url"] = clean_youtube_url(data["video_url"]) + if data["video_url"] is None: + raise LogicError(400, lazy_gettext("Video URL is not a YouTube video URL")) + for key in ["name", "title", "short_desc", "desc", "type", "dev_state", "license", "media_license", - "repo", "website", "issueTracker", "forums"]: + "repo", "website", "issueTracker", "forums", "video_url"]: if key in data: setattr(package, key, data[key]) diff --git a/app/models/packages.py b/app/models/packages.py index 9cdf417..b32d8e7 100644 --- a/app/models/packages.py +++ b/app/models/packages.py @@ -389,6 +389,7 @@ class Package(db.Model): website = db.Column(db.String(200), nullable=True) issueTracker = db.Column(db.String(200), nullable=True) forums = db.Column(db.Integer, nullable=True) + video_url = db.Column(db.String(200), nullable=True, default=None) provides = db.relationship("MetaPackage", secondary=PackageProvides, order_by=db.asc("name"), back_populates="packages") @@ -527,6 +528,7 @@ class Package(db.Model): "website": self.website, "issue_tracker": self.issueTracker, "forums": self.forums, + "video_url": self.video_url, "tags": [x.name for x in self.tags], "content_warnings": [x.name for x in self.content_warnings], diff --git a/app/public/static/video_embed.js b/app/public/static/video_embed.js new file mode 100644 index 0000000..b32c356 --- /dev/null +++ b/app/public/static/video_embed.js @@ -0,0 +1,24 @@ +document.querySelectorAll(".video-embed").forEach(ele => { + const url = new URL(ele.getAttribute("href")); + + if (url.host == "www.youtube.com") { + ele.addEventListener("click", () => { + ele.parentNode.classList.add("d-block"); + ele.classList.add("embed-responsive"); + ele.classList.add("embed-responsive-16by9"); + ele.innerHTML = ` + `; + + const embedURL = new URL("https://www.youtube.com/"); + embedURL.pathname = "/embed/" + url.searchParams.get("v"); + embedURL.searchParams.set("autoplay", "1"); + + const iframe = ele.children[0]; + iframe.setAttribute("src", embedURL); + }); + ele.removeAttribute("href"); + } +}); diff --git a/app/scss/custom.scss b/app/scss/custom.scss index f3e70f7..d40a9be 100644 --- a/app/scss/custom.scss +++ b/app/scss/custom.scss @@ -1,5 +1,6 @@ @import "components.scss"; @import "packages.scss"; +@import "gallery.scss"; @import "packagegrid.scss"; @import "comments.scss"; diff --git a/app/scss/gallery.scss b/app/scss/gallery.scss new file mode 100644 index 0000000..640ca5b --- /dev/null +++ b/app/scss/gallery.scss @@ -0,0 +1,84 @@ +.gallery { + list-style: none; + padding: 0; + margin: 0 0 2em; + overflow: auto hidden; + + li, li a { + list-style: none; + margin: 0; + padding: 0; + } + + li { + display: inline-block; + vertical-align: middle; + margin: 5px; + padding: 0; + + a { + display: block; + text-decoration: none; + + &:hover { + text-decoration: none; + } + } + } + + .gallery-image { + position: relative; + + &:hover img { + filter: brightness(1.1); + } + } + + img { + width: 200px; + height: 133px; + object-fit: cover; + } +} + +.video-embed { + min-width: 200px; + min-height: 133px; + background: #111; + position: relative; + display: flex !important; + align-items: center !important; + justify-content: center !important; + cursor: pointer; + + .fas { + display: block; + font-size: 200%; + color: #f44; + } + + &:hover { + background: #191919; + + .fas { + color: red; + } + } +} + +.screenshot-add { + display: block !important; + width: 200px; + height: 133px; + background: #444; + color: #666; + text-align: center; + line-height: 133px !important; + font-size: 80px; + + &:hover { + background: #555; + color: #999; + text-decoration: none; + } +} \ No newline at end of file diff --git a/app/scss/packages.scss b/app/scss/packages.scss index 9892850..4b4d569 100644 --- a/app/scss/packages.scss +++ b/app/scss/packages.scss @@ -1,32 +1,3 @@ -.screenshot_list { - list-style: none; - padding: 0; - margin: 0 0 2em; - - li, li a { - list-style: none; - margin: 0; - padding: 0; - } - - li { - display: inline-block; - vertical-align: middle; - margin: 5px; - padding: 0; - - a { - display: block; - } - } - - img { - width: 200px; - height: 133px; - object-fit: cover; - } -} - .badge-tr { position: absolute; top: 5px; @@ -34,23 +5,6 @@ color: #ccc !important;; } -.screenshot-add { - display: block !important; - width: 200px; - height: 133px; - background: #444; - color: #666; - text-align: center; - line-height: 133px !important; - font-size: 80px; - - &:hover { - background: #555; - color: #999; - text-decoration: none; - } -} - .info-row { vertical-align: middle; diff --git a/app/templates/base.html b/app/templates/base.html index 9145479..9a4de5d 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,7 +6,7 @@ {% block title %}title{% endblock %} - {{ config.USER_APP_NAME }} - + diff --git a/app/templates/packages/create_edit.html b/app/templates/packages/create_edit.html index 1bb7a3e..3e241a6 100644 --- a/app/templates/packages/create_edit.html +++ b/app/templates/packages/create_edit.html @@ -117,6 +117,7 @@ pattern="[0-9]+", prefix="forum.minetest.net/viewtopic.php?t=", placeholder=_("Tip: paste in a forum topic URL")) }} + {{ render_field(form.video_url, class_="pkg_meta", hint=_("Only supports YouTube, for now")) }}
{{ render_submit_field(form.submit) }}
diff --git a/app/templates/packages/screenshots.html b/app/templates/packages/screenshots.html index 671e941..d4bbda6 100644 --- a/app/templates/packages/screenshots.html +++ b/app/templates/packages/screenshots.html @@ -78,6 +78,11 @@ {{ render_submit_field(form.submit, tabindex=280) }} + +

{{ _("Videos") }}

+

+ {{ _("You can set a video on the Edit Details page") }} +

{% endblock %} {% block scriptextra %} diff --git a/app/templates/packages/view.html b/app/templates/packages/view.html index d0bfd06..8f687a5 100644 --- a/app/templates/packages/view.html +++ b/app/templates/packages/view.html @@ -17,6 +17,10 @@ {% endif %} {% endblock %} +{% block scriptextra %} + +{% endblock %} + {% macro render_license(license) %} {% if license.url %} {{ license.name }} @@ -89,19 +93,19 @@
{% if package.checkPerm(current_user, "EDIT_PACKAGE") %} - + {{ _("Edit") }} {% endif %} {% if package.checkPerm(current_user, "MAKE_RELEASE") %} - + {{ _("Release") }} {% endif %} {% if package.checkPerm(current_user, "DELETE_PACKAGE") or package.checkPerm(current_user, "UNAPPROVE_PACKAGE") %} - + {{ _("Remove") }} @@ -236,33 +240,44 @@
{% set screenshots = package.screenshots.all() %} - {% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") %} - {% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %} - - - {{ _("Edit") }} - - {% endif %} -
    - {% for ss in screenshots %} - {% if ss.approved or package.checkPerm(current_user, "ADD_SCREENSHOTS") %} -
  • - - {{ ss.title }} - {% if not ss.approved %} - {{ _("Awaiting review") }} - {% endif %} - -
  • - {% endif %} - {% else %} + {% if package.checkPerm(current_user, "ADD_SCREENSHOTS") %} + + + {{ _("Edit") }} + + {% endif %} + + {% if screenshots or package.checkPerm(current_user, "ADD_SCREENSHOTS") or package.video_url %} + {% endif %} diff --git a/app/tests/unit/test_url.py b/app/tests/unit/test_url.py new file mode 100644 index 0000000..1510b07 --- /dev/null +++ b/app/tests/unit/test_url.py @@ -0,0 +1,13 @@ +from app.utils.url import clean_youtube_url + + +def test_clean_youtube_url(): + assert clean_youtube_url( + "https://www.youtube.com/watch?v=AABBCC") == "https://www.youtube.com/watch?v=AABBCC" + assert clean_youtube_url( + "https://www.youtube.com/watch?v=boGcB4H5-WA&other=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA" + assert clean_youtube_url("https://www.youtube.com/watch?kk=boGcB4H5-WA&other=1") is None + assert clean_youtube_url("https://www.bob.com/watch?v=AABBCC") is None + + assert clean_youtube_url("https://youtu.be/boGcB4H5-WA") == "https://www.youtube.com/watch?v=boGcB4H5-WA" + assert clean_youtube_url("https://youtu.be/boGcB4H5-WA?this=1") == "https://www.youtube.com/watch?v=boGcB4H5-WA" diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 60284df..3bb6b87 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -14,13 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +import re import secrets from .flask import * from .models import * from .user import * -import re - YESES = ["yes", "true", "1", "on"] diff --git a/app/utils/url.py b/app/utils/url.py new file mode 100644 index 0000000..1114332 --- /dev/null +++ b/app/utils/url.py @@ -0,0 +1,46 @@ +# ContentDB +# Copyright (C) 2022 rubenwardy +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import urllib.parse as urlparse +from typing import Optional, Dict, List + + +def url_set_query(url: str, params: Dict[str, str]) -> str: + url_parts = list(urlparse.urlparse(url)) + query = dict(urlparse.parse_qsl(url_parts[4])) + query.update(params) + + url_parts[4] = urlparse.urlencode(query) + return urlparse.urlunparse(url_parts) + + +def url_get_query(parsed_url: urlparse.ParseResult) -> Dict[str, List[str]]: + return urlparse.parse_qs(parsed_url.query) + + +def clean_youtube_url(url: str) -> Optional[str]: + parsed = urlparse.urlparse(url) + print(parsed) + if (parsed.netloc == "www.youtube.com" or parsed.netloc == "youtube.com") and parsed.path == "/watch": + print(url_get_query(parsed)) + video_id = url_get_query(parsed).get("v", [None])[0] + if video_id: + return url_set_query("https://www.youtube.com/watch", {"v": video_id}) + + elif parsed.netloc == "youtu.be": + return url_set_query("https://www.youtube.com/watch", {"v": parsed.path[1:]}) + + return None diff --git a/migrations/versions/011e42c52d21_.py b/migrations/versions/011e42c52d21_.py new file mode 100644 index 0000000..8927e9e --- /dev/null +++ b/migrations/versions/011e42c52d21_.py @@ -0,0 +1,25 @@ +"""empty message + +Revision ID: 011e42c52d21 +Revises: 6e57b2b4dcdf +Create Date: 2022-01-25 18:48:46.367409 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '011e42c52d21' +down_revision = '6e57b2b4dcdf' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('package', sa.Column('video_url', sa.String(length=200), nullable=True)) + + + +def downgrade(): + op.drop_column('package', 'video_url') \ No newline at end of file