From 82000c109516f121bcd41d329c12c039d57c95c0 Mon Sep 17 00:00:00 2001 From: rimu <3310831+rimu@users.noreply.github.com> Date: Tue, 29 Aug 2023 22:01:06 +1200 Subject: [PATCH] community list and beginning of viewing community --- INSTALL.md | 182 +++++ app/__init__.py | 6 + app/activitypub/routes.py | 18 +- app/activitypub/signature.py | 4 +- app/auth/routes.py | 9 +- app/cli.py | 14 + app/community/__init__.py | 5 + app/community/forms.py | 18 + app/community/routes.py | 44 ++ app/community/util.py | 66 ++ app/constants.py | 5 + app/main/routes.py | 13 +- app/models.py | 51 +- app/static/js/markdown/demo.html | 41 + app/static/js/markdown/editor.css | 31 + app/static/js/markdown/editor.js | 725 ++++++++++++++++++ app/static/js/markdown/index.min.js | 1 + app/static/structure.css | 12 + app/static/structure.scss | 14 + app/static/styles.css | 29 +- app/static/styles.scss | 28 +- app/templates/base.html | 6 +- app/templates/community/add_remote.html | 38 + app/templates/community/community.html | 58 ++ app/templates/list_communities.html | 53 +- app/utils.py | 25 + config.py | 1 + .../versions/feef49234599_community_image.py | 46 ++ pyfedi.py | 9 +- 29 files changed, 1530 insertions(+), 22 deletions(-) create mode 100644 INSTALL.md create mode 100644 app/community/__init__.py create mode 100644 app/community/forms.py create mode 100644 app/community/routes.py create mode 100644 app/community/util.py create mode 100644 app/static/js/markdown/demo.html create mode 100644 app/static/js/markdown/editor.css create mode 100644 app/static/js/markdown/editor.js create mode 100644 app/static/js/markdown/index.min.js create mode 100644 app/templates/community/add_remote.html create mode 100644 app/templates/community/community.html create mode 100644 app/utils.py create mode 100644 migrations/versions/feef49234599_community_image.py diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 00000000..fa74588d --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,182 @@ +Mac OS +--- +Install Python Version Manager (pyenv) +see this site: https://opensource.com/article/19/5/python-3-default-mac + + brew install pyenv + +Install Python3 version and set as default (with pyenv) + + pyenv install 3.8.6 + pyenv global 3.7.3 + +Note.. +You may see this error when running `pip install -r requirements.txt` in regards to psycopg2: + + ld: library not found for -lssl + clang: error: linker command failed with exit code 1 (use -v to see invocation) + error: command 'clang' failed with exit status 1 + +If this happens try installing openssl... +Install openssl with brew install openssl if you don't have it already. + + brew install openssl + +Add openssl path to LIBRARY_PATH : + + export LIBRARY_PATH=$LIBRARY_PATH:/usr/local/opt/openssl/lib/ + +Linux +--- +install these additional packages + +```sudo apt install python3-psycopg2 libpq-dev python3-dev``` + + +Pip Package Management: +--- + +make sure you have 'wheel' installed: + ```pip install wheel``` + +dump currently installed packages to file: + ```pip freeze > requirements.txt``` + +install packages from a file: + ```pip install -r requirements.txt``` + +upgrade a package: + ```pip install --upgrade ``` + + +--- + + +Postgresql Setup: +--- +installing postgresql https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04 + + + +Windows (WSL 2 - Ubuntu 22.04 LTS - Python 3.9.16) +--- +**Important** + Python 3.10+ or 3.11+ may cause some package or compatibility errors. If you are having issues installing packages from + requirements.txt, try using Python 3.8 or 3.9 instead with pyenv (https://github.com/pyenv/pyenv). + Follow all the setup instructions in the pyenv documentation and setup any version of either Python 3.8 or 3.9. + If you are getting installation errors or missing packages with pyenv, run + + sudo apt update + sudo apt install build-essential libssl-dev zlib1g-dev libbz2-dev libreadline-dev libsqlite3-dev curl libncursesw5-dev xz-utils tk-dev libxml2-dev libxmlsec1-dev libffi-dev liblzma-dev llvm + +Install Python 3, pip, and venv + + sudo apt-get update + sudo apt-get upgrade + sudo apt-get install python3 python3-pip ipython3 libpq-dev python3-psycopg2 python3-dev build-essential + sudo apt-get install python3-venv + +Setup venv first before installing other packages +**Note** + (Replace <3.9> with your version number if you are using another version of Python, + e.g. 'sudo apt-get install python3.10-venv' for Python 3.10. Repeat for the rest of the instructions below.) + + python3.9 -m venv ./venv + source venv/bin/activate + +Make sure that your venv is also running the correct version of pyenv. You may need to re-setup venv if you setup venv before pyenv. + +Follow the package installation instructions above to get the packages + + python3.9 -m pip install --upgrade pip setuptools wheel + pip install -r requirements.txt + +Continue with the .env setup and "Run API" sections below. + + +--- + + +.env setup +--- +add something like this to .env + + DATABASE_URL=postgresql+psycopg2://rimu:password@localhost/buddytree + +other environment variables include: + + API_KEY - used to control access. Set this to the same on both the frontend and backend + MAIL_SERVER=email-smtp.us-east-2.amazonaws.com + MAIL_PORT + MAIL_USERNAME= + MAIL_PASSWORD + MAIL_USE_TLS = False + MAIL_USE_SSL = False + EMAIL_FROM + EMAIL_FROM_NAME=BuddyTree + + +Virtual Env setup (inside the api root directory) +--- + python -m venv ./venv + + +--- + + +Database Setup +--- +Inside api dir + source venv/bin/activate (to set up virtual env if necessary) + flask db upgrade + flask drop-constraint file file_user_id_fkey + flask init-db + flask init-intentions + flask init-interests + flask init-ages + flask init-locations + flask init-roles + flask init-topics + flask init-topics2 + flask topic-files + flask init-activity + flask init-timezones + flask init-private-hangout-topics + flask tidy-private-hangout-topics + flask init-hosted + flask init-countries + +In future if you use git pull and notice some new files in migrations/versions/*, you need to do + + flask db upgrade + +--- + + +Run development server +--- + + export FLASK_APP=pyfedi.py + flask run + +To enable debug mode and hot reloading, set the environment variable FLASK_ENV=development + + export FLASK_ENV=development + export FLASK_APP=pyfedi.py + flask run + +Make sure you have activated the venv by running + + source venv/bin/activate +first! + + +Database Changes +--- +create a migration based on recent changes to app/models.py: + + flask db migrate -m "users table" + +run migrations + + flask db upgrade diff --git a/app/__init__.py b/app/__init__.py index 0909cd47..c2ded722 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -12,6 +12,8 @@ from flask_bootstrap import Bootstrap5 from flask_mail import Mail from flask_moment import Moment from flask_babel import Babel, lazy_gettext as _l +from sqlalchemy_searchable import make_searchable + from config import Config @@ -36,6 +38,7 @@ def create_app(config_class=Config): mail.init_app(app) bootstrap.init_app(app) moment.init_app(app) + make_searchable(db.metadata) babel.init_app(app, locale_selector=get_locale) from app.main import bp as main_bp @@ -53,6 +56,9 @@ def create_app(config_class=Config): from app.auth import bp as auth_bp app.register_blueprint(auth_bp, url_prefix='/auth') + from app.community import bp as community_bp + app.register_blueprint(community_bp, url_prefix='/community') + def get_resource_as_string(name, charset='utf-8'): with app.open_resource(name) as f: return f.read().decode(charset) diff --git a/app/activitypub/routes.py b/app/activitypub/routes.py index 784eac23..824fce1d 100644 --- a/app/activitypub/routes.py +++ b/app/activitypub/routes.py @@ -3,6 +3,8 @@ from sqlalchemy import text from app import db from app.activitypub import bp from flask import request, Response, render_template, current_app, abort, jsonify + +from app.community.routes import show_community from app.models import User, Community from app.activitypub.util import public_key, users_total, active_half_year, active_month, local_posts, local_comments, \ post_to_activity @@ -141,9 +143,15 @@ def community_profile(actor): """ Requests to this endpoint can be for a JSON representation of the community, or a HTML rendering of it. The two types of requests are differentiated by the header """ actor = actor.strip() - community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() + if '@' in actor: + # don't provide activitypub info for remote communities + if 'application/ld+json' in request.headers.get('Accept', ''): + abort(404) + community = Community.query.filter_by(ap_id=actor, banned=False).first() + else: + community = Community.query.filter_by(name=actor, banned=False, ap_id=None).first() if community is not None: - if 'application/ld+json' in request.headers.get('Accept', '') or request.accept_mimetypes.accept_json: + if 'application/ld+json' in request.headers.get('Accept', ''): server = current_app.config['SERVER_NAME'] actor_data = {"@context": [ "https://www.w3.org/ns/activitystreams", @@ -188,8 +196,10 @@ def community_profile(actor): resp = jsonify(actor_data) resp.content_type = 'application/activity+json' return resp - else: - return render_template('user_profile.html', user=community) + else: # browser request - return html + return show_community(community) + else: + abort(404) @bp.route('/c//outbox', methods=['GET']) diff --git a/app/activitypub/signature.py b/app/activitypub/signature.py index 3fcbec47..ae3e1146 100644 --- a/app/activitypub/signature.py +++ b/app/activitypub/signature.py @@ -39,7 +39,7 @@ import arrow from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding, rsa -from flask import Request +from flask import Request, current_app from datetime import datetime from dateutil import parser from pyld import jsonld @@ -313,7 +313,7 @@ class HttpSignature: ) except requests.exceptions.SSLError as invalid_cert: # Not our problem if the other end doesn't have proper SSL - print(f"{uri} {invalid_cert}") + current_app.logger.info(f"{uri} {invalid_cert}") raise requests.exceptions.SSLError from invalid_cert except ValueError as ex: # Convert to a more generic error we handle diff --git a/app/auth/routes.py b/app/auth/routes.py index a03fd80d..e5537e01 100644 --- a/app/auth/routes.py +++ b/app/auth/routes.py @@ -9,7 +9,7 @@ from app.auth.forms import LoginForm, RegistrationForm, ResetPasswordRequestForm from app.auth.util import random_token from app.models import User from app.auth.email import send_password_reset_email, send_welcome_email, send_verification_email -from sqlalchemy import text +from app.activitypub.signature import RsaKeys @bp.route('/login', methods=['GET', 'POST']) @@ -132,10 +132,15 @@ def verify_email(token): if token != '': user = User.query.filter_by(verification_token=token).first() if user is not None: + if user.verified: # guard against users double-clicking the link in the email + return redirect(url_for('main.index')) user.verified = True user.last_seen = datetime.utcnow() + private_key, public_key = RsaKeys.generate_keypair() + user.private_key = private_key + user.public_key = public_key db.session.commit() - flash(_('Thanks for verifying your email address.')) + flash(_('Thank you for verifying your email address. You can now post content and vote.')) else: flash(_('Email address validation failed.'), 'error') return redirect(url_for('main.index')) \ No newline at end of file diff --git a/app/cli.py b/app/cli.py index d24f7cbd..a9ef11da 100644 --- a/app/cli.py +++ b/app/cli.py @@ -1,3 +1,8 @@ +# if commands in this file are not working (e.g. 'flask translate') make sure you set the FLASK_APP environment variable. +# e.g. export FLASK_APP=pyfedi.py + + +from app import db import click import os @@ -33,3 +38,12 @@ def register(app): """Compile all languages.""" if os.system('pybabel compile -d app/translations'): raise RuntimeError('compile command failed') + + @app.cli.command("init-db") + def init_db(): + with app.app_context(): + db.drop_all() + db.configure_mappers() + db.create_all() + db.session.commit() + print("Done") diff --git a/app/community/__init__.py b/app/community/__init__.py new file mode 100644 index 00000000..8cbdaeb8 --- /dev/null +++ b/app/community/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('community', __name__) + +from app.community import routes diff --git a/app/community/forms.py b/app/community/forms.py new file mode 100644 index 00000000..a6837317 --- /dev/null +++ b/app/community/forms.py @@ -0,0 +1,18 @@ +from flask_wtf import FlaskForm +from wtforms import StringField, SubmitField, TextAreaField, BooleanField +from wtforms.validators import ValidationError, DataRequired, Email, EqualTo, Length +from flask_babel import _, lazy_gettext as _l + + +class AddLocalCommunity(): + name = StringField(_l('Name'), validators=[DataRequired()]) + url = StringField(_l('Url')) + description = TextAreaField(_l('Description')) + rules = TextAreaField(_l('Rules')) + nsfw = BooleanField('18+ NSFW') + submit = SubmitField(_l('Create')) + + +class SearchRemoteCommunity(FlaskForm): + address = StringField(_l('Server address'), validators=[DataRequired()]) + submit = SubmitField(_l('Search')) \ No newline at end of file diff --git a/app/community/routes.py b/app/community/routes.py new file mode 100644 index 00000000..0c7dd108 --- /dev/null +++ b/app/community/routes.py @@ -0,0 +1,44 @@ +from datetime import date, datetime, timedelta +from flask import render_template, redirect, url_for, flash, request, make_response, session, Markup, current_app +from flask_login import login_user, logout_user, current_user +from flask_babel import _ +from app import db +from app.community.forms import SearchRemoteCommunity +from app.community.util import search_for_community +from app.constants import SUBSCRIPTION_MEMBER +from app.models import User, Community +from app.community import bp + + +@bp.route('/add_local', methods=['GET', 'POST']) +def add_local(): + form = AddLocalCommunity() + if form.validate_on_submit(): + ... + + +@bp.route('/add_remote', methods=['GET', 'POST']) +def add_remote(): + form = SearchRemoteCommunity() + new_community = None + if form.validate_on_submit(): + address = form.address.data.strip() + if address.startswith('!') and '@' in address: + new_community = search_for_community(address) + elif address.startswith('@') and '@' in address[1:]: + # todo: the user is searching for a person instead + ... + elif '@' in address: + new_community = search_for_community('!' + address) + else: + message = Markup('Type address in the format !community@server.name. Search on Lemmyverse.net to find some.') + flash(message, 'error') + + return render_template('community/add_remote.html', + title=_('Add remote community'), form=form, new_community=new_community, + subscribed=current_user.subscribed(new_community) >= SUBSCRIPTION_MEMBER) + + +# @bp.route('/c/', methods=['GET']) - defined in activitypub/routes.py, which calls this function for user requests. A bit weird. +def show_community(community: Community): + return render_template('community/community.html', community=community, title=community.title) diff --git a/app/community/util.py b/app/community/util.py new file mode 100644 index 00000000..c7634b4c --- /dev/null +++ b/app/community/util.py @@ -0,0 +1,66 @@ +from datetime import datetime + +from app import db +from app.models import Community, File, BannedInstances +from app.utils import get_request + + +def search_for_community(address: str): + if address.startswith('!'): + name, server = address[1:].split('@') + + banned = BannedInstances.query.filter_by(domain=server).first() + if banned: + reason = f" Reason: {banned.reason}" if banned.reason is not None else '' + raise Exception(f"{server} is blocked.{reason}") # todo: create custom exception class hierarchy + + already_exists = Community.query.filter_by(ap_id=address[1:]).first() + if already_exists: + return already_exists + + # Look up the profile address of the community using WebFinger + # todo: try, except block around every get_request + webfinger_data = get_request(f"https://{server}/.well-known/webfinger", + params={'resource': f"acct:{address[1:]}"}) + if webfinger_data.status_code == 200: + webfinger_json = webfinger_data.json() + for links in webfinger_json['links']: + if 'rel' in links and links['rel'] == 'self': # this contains the URL of the activitypub profile + type = links['type'] if 'type' in links else 'application/activity+json' + # retrieve the activitypub profile + community_data = get_request(links['href'], headers={'Accept': type}) + # to see the structure of the json contained in community_data, do a GET to https://lemmy.world/c/technology with header Accept: application/activity+json + if community_data.status_code == 200: + community_json = community_data.json() + if community_json['type'] == 'Group': + community = Community(name=community_json['preferredUsername'], + title=community_json['name'], + description=community_json['summary'], + nsfw=community_json['sensitive'], + restricted_to_mods=community_json['postingRestrictedToMods'], + created_at=community_json['published'], + last_active=community_json['updated'], + ap_id=f"{address[1:]}", + ap_public_url=community_json['id'], + ap_profile_id=community_json['id'], + ap_followers_url=community_json['followers'], + ap_inbox_url=community_json['endpoints']['sharedInbox'], + ap_fetched_at=datetime.utcnow(), + ap_domain=server, + public_key=community_json['publicKey']['publicKeyPem'], + # language=community_json['language'][0]['identifier'] # todo: language + ) + if 'icon' in community_json: + # todo: retrieve icon, save to disk, save more complete File record + icon = File(source_url=community_json['icon']['url']) + community.icon = icon + db.session.add(icon) + if 'image' in community_json: + # todo: retrieve image, save to disk, save more complete File record + image = File(source_url=community_json['image']['url']) + community.image = image + db.session.add(image) + db.session.add(community) + db.session.commit() + return community + return None diff --git a/app/constants.py b/app/constants.py index aa8bded0..1bdcbc49 100644 --- a/app/constants.py +++ b/app/constants.py @@ -6,3 +6,8 @@ POST_TYPE_IMAGE = 3 POST_TYPE_VIDEO = 4 DATETIME_MS_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + +SUBSCRIPTION_OWNER = 3 +SUBSCRIPTION_MODERATOR = 2 +SUBSCRIPTION_MEMBER = 1 +SUBSCRIPTION_NONMEMBER = 0 diff --git a/app/main/routes.py b/app/main/routes.py index 901255a7..3874fb3a 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -9,11 +9,6 @@ from flask_babel import _, get_locale from app.models import Community -@bp.before_app_request -def before_request(): - g.locale = str(get_locale()) - - @bp.route('/', methods=['GET', 'POST']) @bp.route('/index', methods=['GET', 'POST']) def index(): @@ -25,4 +20,10 @@ def index(): @bp.route('/communities', methods=['GET']) def list_communities(): communities = Community.query.all() - return render_template('list_communities.html', communities=communities) \ No newline at end of file + return render_template('list_communities.html', communities=communities) + + + +@bp.before_app_request +def before_request(): + g.locale = str(get_locale()) \ No newline at end of file diff --git a/app/models.py b/app/models.py index 3c1a964a..57280ce2 100644 --- a/app/models.py +++ b/app/models.py @@ -10,6 +10,8 @@ from sqlalchemy_utils.types import TSVectorType # https://sqlalchemy-searchable. from app import db, login import jwt +from app.constants import SUBSCRIPTION_NONMEMBER, SUBSCRIPTION_MEMBER, SUBSCRIPTION_MODERATOR, SUBSCRIPTION_OWNER + class File(db.Model): id = db.Column(db.Integer, primary_key=True) @@ -24,6 +26,7 @@ class File(db.Model): class Community(db.Model): id = db.Column(db.Integer, primary_key=True) icon_id = db.Column(db.Integer, db.ForeignKey('file.id')) + image_id = db.Column(db.Integer, db.ForeignKey('file.id')) name = db.Column(db.String(256), index=True) title = db.Column(db.String(256)) description = db.Column(db.Text) @@ -53,10 +56,40 @@ class Community(db.Model): restricted_to_mods = db.Column(db.Boolean, default=False) searchable = db.Column(db.Boolean, default=True) - search_vector = db.Column(TSVectorType('name', 'title', 'description')) + search_vector = db.Column(TSVectorType('name', 'title', 'description', 'rules')) posts = db.relationship('Post', backref='community', lazy='dynamic', cascade="all, delete-orphan") replies = db.relationship('PostReply', backref='community', lazy='dynamic', cascade="all, delete-orphan") + icon = db.relationship('File', foreign_keys=[icon_id], single_parent=True, backref='community', cascade="all, delete-orphan") + image = db.relationship('File', foreign_keys=[image_id], single_parent=True, cascade="all, delete-orphan") + + def icon_image(self) -> str: + if self.icon_id is not None: + if self.icon.file_path is not None: + return self.icon.file_path + if self.icon.source_url is not None: + return self.icon.source_url + return '' + + def header_image(self) -> str: + if self.image_id is not None: + if self.image.file_path is not None: + return self.image.file_path + if self.image.source_url is not None: + return self.image.source_url + return '' + + def display_name(self) -> str: + if self.ap_id is None: + return self.title + else: + return f"{self.title}@{self.ap_domain}" + + def link(self) -> str: + if self.ap_id is None: + return self.name + else: + return self.ap_id class User(UserMixin, db.Model): @@ -147,6 +180,20 @@ class User(UserMixin, db.Model): return True return self.expires < datetime(2019, 9, 1) + def subscribed(self, community) -> int: + if community is None: + return False + subscription:CommunityMember = CommunityMember.query.filter_by(user_id=self.id, community_id=community.id).first() + if subscription: + if subscription.is_owner: + return SUBSCRIPTION_OWNER + elif subscription.is_moderator: + return SUBSCRIPTION_MODERATOR + else: + return SUBSCRIPTION_MEMBER + else: + return SUBSCRIPTION_NONMEMBER + @staticmethod def verify_reset_password_token(token): try: @@ -282,7 +329,7 @@ class BannedInstances(db.Model): class Instance(db.Model): id = db.Column(db.Integer, primary_key=True) - domain = db.Column(db.String(256)) + domain = db.Column(db.String(256), index=True) inbox = db.Column(db.String(256)) shared_inbox = db.Column(db.String(256)) outbox = db.Column(db.String(256)) diff --git a/app/static/js/markdown/demo.html b/app/static/js/markdown/demo.html new file mode 100644 index 00000000..e09f3597 --- /dev/null +++ b/app/static/js/markdown/demo.html @@ -0,0 +1,41 @@ + + + + + + + + + + +
+
+ + + + + + +
+
+ + diff --git a/app/static/js/markdown/editor.css b/app/static/js/markdown/editor.css new file mode 100644 index 00000000..780eff4c --- /dev/null +++ b/app/static/js/markdown/editor.css @@ -0,0 +1,31 @@ +.easy-markdown{ + position: absolute; + display: block; + left:0; + top:0; + width: 100%; + webkit-transition: left 0.7s ease-in-out 0.1s; + -moz-transition : left 0.7s ease-in-out 0.1s; + -ms-transition : left 0.7s ease-in-out 0.1s; + -o-transition : left 0.7s ease-in-out 0.1s; + transition : left 0.7s ease-in-out 0.1s; +} +.easy-preview{ + position: absolute; + display: block; + top:0; + left: 100%; + width: 100%; + + webkit-transition: left 0.7s ease-in-out 0.1s; + -moz-transition : left 0.7s ease-in-out 0.1s; + -ms-transition : left 0.7s ease-in-out 0.1s; + -o-transition : left 0.7s ease-in-out 0.1s; + transition : left 0.7s ease-in-out 0.1s; +} +.easy-markdown.is-hidden{ + left:-105%; +} +.easy-preview.is-visible{ + left:0; +} \ No newline at end of file diff --git a/app/static/js/markdown/editor.js b/app/static/js/markdown/editor.js new file mode 100644 index 00000000..48b8f908 --- /dev/null +++ b/app/static/js/markdown/editor.js @@ -0,0 +1,725 @@ +// MIT license + +var easyMarkdown = (function() { + 'use strict'; + var regexplink = new RegExp( + '^' + + // protocol identifier + '(?:(?:https?|ftp)://)' + + // user:pass authentication + '(?:\\S+(?::\\S*)?@)?' + + '(?:' + + // IP address exclusion + // private & local networks + '(?!(?:10|127)(?:\\.\\d{1,3}){3})' + + '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' + + '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' + + // IP address dotted notation octets + // excludes loopback network 0.0.0.0 + // excludes reserved space >= 224.0.0.0 + // excludes network & broacast addresses + // (first & last IP address of each class) + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' + + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' + + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' + + '|' + + // host name + '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' + + // domain name + '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' + + // TLD identifier + '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' + + ')' + + // port number + '(?::\\d{2,5})?' + + // resource path + '(?:/\\S*)?' + + '$', 'i' + ); + var regexppic = new RegExp( + '^' + + // protocol identifier + '(?:(?:https?|ftp)://)' + + // user:pass authentication + '(?:\\S+(?::\\S*)?@)?' + + '(?:' + + // IP address exclusion + // private & local networks + '(?!(?:10|127)(?:\\.\\d{1,3}){3})' + + '(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})' + + '(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})' + + // IP address dotted notation octets + // excludes loopback network 0.0.0.0 + // excludes reserved space >= 224.0.0.0 + // excludes network & broacast addresses + // (first & last IP address of each class) + '(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' + + '(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' + + '(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' + + '|' + + // host name + '(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)' + + // domain name + '(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*' + + // TLD identifier + '(?:\\.(?:[a-z\\u00a1-\\uffff]{2,}))' + + ')' + + // port number + '(?::\\d{2,5})?' + + // resource path + '(?:/\\S*)?' + + // image + '(?:jpg|gif|png)'+ + '$', 'i' + ); + function createDom(obj){ + var nodeArray = []; + for ( var i in obj){ + var node = document.createElement(obj[i].type); + for ( var j in obj[i].attrs) + node.setAttribute( j, obj[i].attrs[j]); + if (obj[i].text) + node.appendChild(document.createTextNode(obj[i].text)); + nodeArray[i] = node; + if (typeof(obj[i].childrenOf) !== 'undefined') + nodeArray[obj[i].childrenOf].appendChild(node); + } + return nodeArray[0]; + } + function createNode(el,attrs,text){ + var node = document.createElement(el); + for(var key in attrs) + node.setAttribute(key, attrs[key]); + if (text) + node.appendChild(document.createTextNode(text)); + return node; + } + function applyStyle(el,attrs){ + for(var key in attrs) + el.style[key] = attrs[key]; + } + function merge(obj) { + var i = 1,target, key; + for (; i < arguments.length; i += 1) { + target = arguments[i]; + for (key in target) + if (Object.prototype.hasOwnProperty.call(target, key)) + obj[key] = target[key]; + } + return obj; + } + function getStyle (el,styleProp){ + var y; + if (el.currentStyle) + y = el.currentStyle[styleProp]; + else if (window.getComputedStyle) + y = document.defaultView.getComputedStyle(el,null).getPropertyValue(styleProp); + return y; + } + function easyMarkdown(node, options) { + return new Editor(node, options); + } + /*========== BUTTONS ==========*/ + function Buttons(element,options,buttons) { + this.element = element; + this.options = options; + this.locale = merge({}, easyMarkdown.locale, easyMarkdown.locale[options.locale] || {}); + this.buttons = { + header: { + title : this.locale.header.title, + text : 'header', + group : 0, + callback : function(e) { + // Append/remove ### surround the selection + var chunk, cursor, selected = e.getSelection(), + content = e.getContent(); + if (selected.length === 0) { + // Give extra word + chunk = e.locale.header.description + '\n'; + } else { + chunk = selected.text + '\n'; + } + var key = 0, + hash='', + start = selected.start-1, + end = selected.start, + prevChr = content.substring(start,end); + while (/^\s+$|^#+$/.test(prevChr)){ + if (/^#+$/.test(prevChr)) + hash = hash+'#'; + key +=1; + prevChr = content.substring(start-key,end-key); + } + + if (hash.length > 0){ + // already a title + var startLinePos, + endLinePos = content.indexOf('\n', selected.start); + + // more than ### -> # + if (hash.length > 2){ + hash = '#'; + startLinePos = content.indexOf('\n', selected.start - 5); + e.setSelection(startLinePos, endLinePos+1); + e.replaceSelection('\n'+hash+' '+chunk); + cursor = startLinePos+3; + }else{ + hash = hash +'#'; + startLinePos = content.indexOf('\n', selected.start - (hash.length + 1)); + e.setSelection(startLinePos, endLinePos+1); + e.replaceSelection('\n'+hash+' '+chunk); + cursor = selected.start + 1; + } + }else{ + + // new title + hash= '#'; + e.replaceSelection('\n'+hash+' '+ chunk); + cursor = selected.start + 3; + } + e.setSelection(cursor, cursor + chunk.length-1); + return false; + } + }, + bold: { + title : this.locale.bold.title, + text : 'bold', + group : 0, + callback : function(e) { + // Give/remove ** surround the selection + var chunk, cursor, selected = e.getSelection(), + content = e.getContent(); + if (selected.length === 0) { + // Give extra word + chunk = e.locale.bold.description; + } else { + chunk = selected.text; + } + // transform selection and set the cursor into chunked text + if (content.substr(selected.start - 2, 2) === '**' && content.substr(selected.end, 2) === '**') { + e.setSelection(selected.start - 2, selected.end + 2); + e.replaceSelection(chunk); + cursor = selected.start - 2; + } else { + e.replaceSelection('**' + chunk + '**'); + cursor = selected.start + 2; + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + italic: { + title : this.locale.italic.title, + text : 'italic', + group : 0, + callback : function(e) { + // Give/remove * surround the selection + var chunk, cursor, selected = e.getSelection(), + content = e.getContent(); + if (selected.length === 0) { + // Give extra word + chunk = e.locale.italic.description; + } else { + chunk = selected.text; + } + // transform selection and set the cursor into chunked text + if (content.substr(selected.start - 1, 1) === '_' && content.substr(selected.end, 1) === '_') { + e.setSelection(selected.start - 1, selected.end + 1); + e.replaceSelection(chunk); + cursor = selected.start - 1; + } else { + e.replaceSelection('_' + chunk + '_'); + cursor = selected.start + 1; + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + image: { + title : this.locale.image.title, + text : 'image', + group : 1, + callback : function(e) { + // Give ![] surround the selection and prepend the image link + var chunk, cursor, selected = e.getSelection(), + link; + if (selected.length === 0) { + // Give extra word + chunk = e.locale.image.description; + } else { + chunk = selected.text; + } + link = prompt(e.locale.image.title, 'http://'); + if (regexppic.test(link)) { + e.replaceSelection('![' + chunk + '](' + link + ' "' + e.locale.image.description + '")'); + cursor = selected.start + 2; + e.setSelection(cursor, cursor + chunk.length); + } + return false; + } + }, + link: { + title : this.locale.link.title, + text : 'link', + group : 1, + callback : function(e) { + // Give [] surround the selection and prepend the link + var chunk, cursor, selected = e.getSelection(), + link; + if (selected.length === 0) { + // Give extra word + chunk = e.locale.link.description; + } else { + chunk = selected.text; + } + link = prompt(e.locale.link.title, 'http://'); + if (regexplink.test(link)) { + e.replaceSelection('[' + chunk + '](' + link + ')'); + cursor = selected.start + 1; + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + return false; + } + }, + ol: { + title : this.locale.ol.title, + text : 'ol', + group : 2, + callback : function(e) { + // Prepend/Give - surround the selection + var chunk, cursor, selected = e.getSelection(); + // transform selection and set the cursor into chunked text + if (selected.length === 0) { + // Give extra word + chunk = e.locale.ol.description; + e.replaceSelection('1. ' + chunk); + // Set the cursor + cursor = selected.start + 3; + } else { + if (selected.text.indexOf('\n') < 0) { + chunk = selected.text; + e.replaceSelection('1. ' + chunk); + // Set the cursor + cursor = selected.start + 3; + } else { + var list = []; + list = selected.text.split('\n'); + chunk = list[0]; + for (var key in list) { + var index = parseInt(key) + parseInt(1); + list[key] = index + '. ' + list[key]; + } + e.replaceSelection('\n\n' + list.join('\n')); + // Set the cursor + cursor = selected.start + 5; + } + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + ul: { + title : this.locale.ul.title, + text : 'ul', + group : 2, + callback : function(e) { + // Prepend/Give - surround the selection + var chunk, cursor, selected = e.getSelection(); + // transform selection and set the cursor into chunked text + if (selected.length === 0) { + // Give extra word + chunk = e.locale.ul.description; + e.replaceSelection('- ' + chunk); + // Set the cursor + cursor = selected.start + 2; + } else { + if (selected.text.indexOf('\n') < 0) { + chunk = selected.text; + e.replaceSelection('- ' + chunk); + // Set the cursor + cursor = selected.start + 2; + } else { + var list = []; + list = selected.text.split('\n'); + chunk = list[0]; + for (var key in list) { + list[key] = '- ' + list[key]; + } + e.replaceSelection('\n\n' + list.join('\n')); + // Set the cursor + cursor = selected.start + 4; + } + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + comment: { + title : this.locale.comment.title, + text : 'comment', + group : 3, + callback : function(e) { + // Prepend/Give - surround the selection + var chunk, cursor, selected = e.getSelection(), + content = e.getContent(); + // transform selection and set the cursor into chunked text + if (selected.length === 0) { + // Give extra word + chunk = e.locale.comment.description; + e.replaceSelection('> ' + chunk); + // Set the cursor + cursor = selected.start + 2; + } else { + if (selected.text.indexOf('\n') < 0) { + chunk = selected.text; + e.replaceSelection('> ' + chunk); + // Set the cursor + cursor = selected.start + 2; + } else { + var list = []; + list = selected.text.split('\n'); + chunk = list[0]; + for (var key in list) + list[key] = '> ' + list[key]; + e.replaceSelection('\n\n' + list.join('\n')); + // Set the cursor + cursor = selected.start + 4; + } + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + code: { + title : this.locale.code.title, + text : 'code', + group : 3, + callback : function(e) { + // Give/remove ** surround the selection + var chunk, cursor, selected = e.getSelection(), + content = e.getContent(); + if (selected.length === 0) { + // Give extra word + chunk = e.locale.code.description; + } else { + chunk = selected.text; + } + // transform selection and set the cursor into chunked text + if (content.substr(selected.start - 4, 4) === '```\n' && content.substr(selected.end, 4) === '\n```') { + e.setSelection(selected.start - 4, selected.end + 4); + e.replaceSelection(chunk); + cursor = selected.start - 4; + } else if (content.substr(selected.start - 1, 1) === '`' && content.substr(selected.end, 1) === '`') { + e.setSelection(selected.start - 1, selected.end + 1); + e.replaceSelection(chunk); + cursor = selected.start - 1; + } else if (content.indexOf('\n') > -1) { + e.replaceSelection('```\n' + chunk + '\n```'); + cursor = selected.start + 4; + } else { + e.replaceSelection('`' + chunk + '`'); + cursor = selected.start + 1; + } + // Set the cursor + e.setSelection(cursor, cursor + chunk.length); + } + }, + preview: { + title : this.locale.preview.title, + text : 'preview', + group : 4, + callback : function(e) { + var txteditor = document.getElementById('easy-markdown'); + var preview = document.getElementById('easy-preview'); + var button = document.getElementById('easy-preview-close'); + button.removeAttribute('disabled'); + //preview.childNodes[1].childNodes[0].innerHTML = markdown.toHTML(e.element.value); + var md = window.markdownit(); + + preview.childNodes[1].innerHTML = md.render(e.element.value); + txteditor.classList.add('is-hidden'); + preview.classList.add('is-visible'); + } + } + }; + if (this.options.framework === 'bootstrap' || this.options.framework === 'foundation'){ + return this[this.options.framework](); + }else{ + return this.none(); + } + } + Buttons.prototype = { + getContent: function() { + return this.element.value; + }, + findSelection: function(chunk) { + var content = this.getContent(), + startChunkPosition; + if (startChunkPosition = content.indexOf(chunk), startChunkPosition >= 0 && chunk.length > 0) { + var oldSelection = this.getSelection(), + selection; + this.setSelection(startChunkPosition, startChunkPosition + chunk.length); + selection = this.getSelection(); + this.setSelection(oldSelection.start, oldSelection.end); + return selection; + } else { + return null; + } + }, + getSelection: function() { + var e = this.element; + return ( + ('selectionStart' in e && function() { + var l = e.selectionEnd - e.selectionStart; + return { + start: e.selectionStart, + end: e.selectionEnd, + length: l, + text: e.value.substr(e.selectionStart, l) + }; + }) || + /* browser not supported */ + function() { + return null; + } + )(); + }, + setIcons: function(element,button){ + if (typeof(this.options.icons) === 'string'){ + var t = document.createTextNode(element.title); + button.appendChild(t); + + }else{ + var i = document.createElement('I'); + i.setAttribute('class', this.options.icons[element.text]); + button.appendChild(i); + } + }, + setListener: function(node) { + var that = this; + node.addEventListener('click', function(e) { + var element = e.target, + target = (element.nodeName === 'I') ? element.parentNode : element; + that.buttons[target.getAttribute('data-md')].callback(that); + e.preventDefault(); + }, false); + return node; + }, + setSelection: function(start, end) { + var e = this.element; + return ( + ('selectionStart' in e && function() { + e.selectionStart = start; + e.selectionEnd = end; + return; + }) || + /* browser not supported */ + function() { + return null; + } + )(); + }, + replaceSelection: function(text) { + var e = this.element; + return ( + ('selectionStart' in e && function() { + e.value = e.value.substr(0, e.selectionStart) + text + e.value.substr(e.selectionEnd, e.value.length); + // Set cursor to the last replacement end + e.selectionStart = e.value.length; + return this; + }) + )(); + } + }; + Buttons.prototype.bootstrap = function(){ + var button_groups = createDom ({ + 0 : {'type':'div'}, + 1 : {'type':'div','attrs': {'class':'btn-group','role':'group','style':'margin:5px;'},'childrenOf': 0}, + 2 : {'type':'div','attrs': {'class':'btn-group','role':'group','style':'margin:5px;'},'childrenOf': 0}, + 3 : {'type':'div','attrs': {'class':'btn-group','role':'group','style':'margin:5px;'},'childrenOf': 0}, + 4 : {'type':'div','attrs': {'class':'btn-group','role':'group','style':'margin:5px;'},'childrenOf': 0}, + 5 : {'type':'div','attrs': {'class':'btn-group','role':'group','style':'margin:5px;'},'childrenOf': 0} + }); + for (var i in this.buttons) { + var obj = this.buttons[i]; + if (this.options.disabled[obj.text] !== true) { + var button = createNode('BUTTON',{'class':this.options.btnClass,'data-md':obj.text,'title':obj.title }); + this.setIcons(obj,button); + button_groups.childNodes[obj.group].appendChild(button); + } + } + this.setListener(button_groups); + return button_groups; + }; + Buttons.prototype.foundation = function(){ + var container = createNode('UL',{'class': 'button-group','style':'margin: 0 0 10px 0;'}); + for (var i in this.buttons) { + var obj = this.buttons[i]; + if (this.options.disabled[obj.text] !== true) { + var li = createNode('LI'); + var a = createNode('A',{'class':this.options.btnClass,'data-md':obj.text,'title':obj.title }); + this.setIcons(obj,a); + container.appendChild(li).appendChild(a); + } + } + this.setListener(container); + return container; + }; + Buttons.prototype.none = function(){ + var container = document.createElement('DIV'); + for (var key in this.buttons) { + var obj = this.buttons[key]; + if (this.options.disabled[obj.text] !== true) { + var button = createNode('BUTTON',{'class':this.options.btnClass,'data-md':obj.text,'title':obj.title }); + this.setIcons(obj,button); + container.appendChild(button); + } + } + this.setListener(container); + return container; + }; + /*========== SKELETON ==========*/ + + function Skeleton(options,textarea,buttons) { + this.element = textarea; + this.options = options; + this.buttons = buttons; + return this.build(); + } + Skeleton.prototype = { + build : function(){ + var buttons = new Buttons(this.element,this.options,this.buttons); + var dom = createDom ({ + 0 : {'type':'div','attrs': {'class':'easy-markdown','id':'easy-markdown'}}, + 1 : {'type':'div','attrs': {'id':'easy-markdown-buttons'},'childrenOf': 0}, + 2 : {'type':'div','attrs': {'id':'easy-markdown-textarea'},'childrenOf': 0} + }); + dom.childNodes[0].appendChild(buttons); + dom.childNodes[1].appendChild(this.element); + return dom; + } + }; + /*========== PREVIEW ==========*/ + function Preview(options,parent) { + this.parent = parent; + this.options = options; + this.locale = merge({}, easyMarkdown.locale, easyMarkdown.locale[options.locale] || {}); + return this.build(); + } + Preview.prototype = { + build: function() { + + var dom = createDom ({ + 0 : {'type':'div','attrs': {'class':'easy-preview','id':'easy-preview','style':'height:'+this.parent.clientHeight +'px;'}}, + 1 : {'type':'div','attrs': {'id':'easy-preview-buttons'},'childrenOf': 0}, + 2 : {'type':'div','attrs': {'id':'easy-preview-html','style':'overflow:auto;'},'childrenOf': 0}, + 3 : {'type':'button','attrs':{'class':this.options.btnClass,'disabled':'disabled','id':'easy-preview-close'},'text':this.locale.getback.title,'childrenOf': 1}, + 4 : {'type':'HR','childrenOf':1} + }); + this.setListener(dom.childNodes[0].childNodes[0]); + this.parent.appendChild(dom); + dom.childNodes[1].style.height = this.parent.clientHeight - dom.childNodes[0].clientHeight - 20 +'px'; + + }, + setListener : function(node){ + node.addEventListener('click', function(e) { + var txteditor = document.getElementById('easy-markdown'); + var preview = document.getElementById('easy-preview'); + var button = document.getElementById('easy-preview-close'); + button.setAttribute('disabled','disabled'); + txteditor.classList.remove('is-hidden'); + preview.classList.remove('is-visible'); + e.preventDefault(); + }, false); + return node; + } + }; + /*========== EDITOR ==========*/ + function Editor(node, options) { + this.element = node; + this.parent = node.parentNode; + this.parent.innerHTML = ''; + this.options = merge({}, Editor.defaults, options || {}); + // test if markdown.js is missing + if (typeof(markdownit) === 'undefined') + this.options.disabled.preview = true; + this.preview = 'off'; + var skeleton = new Skeleton(this.options,this.element); + node.style.width = this.options.width; + this.parent.appendChild(skeleton); + + applyStyle(this.parent,{position:'relative',height:skeleton.clientHeight+'px',overflow:'hidden'}); + new Preview(this.options,this.parent); + } + easyMarkdown.Preview = Preview; + easyMarkdown.Buttons = Buttons; + easyMarkdown.Skeleton = Skeleton; + easyMarkdown.Editor = Editor; + easyMarkdown.locale = { + bold: { + title:'Bold', + description:'Strong Text' + }, + italic: { + title:'Italic' , + description: 'Emphasized text' + }, + header: { + title:'Header', + description: 'Heading text' + }, + image: { + title:'Image', + description:'Image description' + }, + link: { + title:'Link', + description:'Link description' + }, + ol: { + title:'Numbered', + description:'Numbered list' + }, + ul: { + title:'Bullet', + description:'Bulleted list' + }, + comment: { + title:'Comment', + description: 'Comment' + }, + code: { + title:'Code', + description: 'code text' + }, + preview: { + title:'Preview' + }, + getback: { + title: 'Get back' + } + }; + Editor.defaults = { + width: '100%', + btnClass: '', + framework: 'none', + locale:'', + icons: '', + disabled: { + bold: false, + italic: false, + header: false, + image: false, + link: false, + ol: false, + ul: false, + comment: false, + code: false, + preview: false + } + }; + + return easyMarkdown; + +})(); diff --git a/app/static/js/markdown/index.min.js b/app/static/js/markdown/index.min.js new file mode 100644 index 00000000..d4a7b8ef --- /dev/null +++ b/app/static/js/markdown/index.min.js @@ -0,0 +1 @@ +var easyMarkdown=function(){"use strict";var t=new RegExp("^(?:(?:https?|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:/\\S*)?$","i"),e=new RegExp("^(?:(?:https?|ftp)://)(?:\\S+(?::\\S*)?@)?(?:(?!(?:10|127)(?:\\.\\d{1,3}){3})(?!(?:169\\.254|192\\.168)(?:\\.\\d{1,3}){2})(?!172\\.(?:1[6-9]|2\\d|3[0-1])(?:\\.\\d{1,3}){2})(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))|(?:(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)(?:\\.(?:[a-z\\u00a1-\\uffff0-9]-*)*[a-z\\u00a1-\\uffff0-9]+)*(?:\\.(?:[a-z\\u00a1-\\uffff]{2,})))(?::\\d{2,5})?(?:/\\S*)?(?:jpg|gif|png)$","i");function i(t){var e=[];for(var i in t){var n=document.createElement(t[i].type);for(var l in t[i].attrs)n.setAttribute(l,t[i].attrs[l]);t[i].text&&n.appendChild(document.createTextNode(t[i].text)),e[i]=n,void 0!==t[i].childrenOf&&e[t[i].childrenOf].appendChild(n)}return e[0]}function n(t,e,i){var n=document.createElement(t);for(var l in e)n.setAttribute(l,e[l]);return i&&n.appendChild(document.createTextNode(i)),n}function l(t){for(var e,i,n=1;n0){var d,p=l.indexOf("\n",n.start);r.length>2?(r="#",d=l.indexOf("\n",n.start-5),t.setSelection(d,p+1),t.replaceSelection("\n"+r+" "+e),i=d+3):(r+="#",d=l.indexOf("\n",n.start-(r.length+1)),t.setSelection(d,p+1),t.replaceSelection("\n"+r+" "+e),i=n.start+1)}else r="#",t.replaceSelection("\n"+r+" "+e),i=n.start+3;return t.setSelection(i,i+e.length-1),!1}},bold:{title:this.locale.bold.title,text:"bold",group:0,callback:function(t){var e,i,n=t.getSelection(),l=t.getContent();e=0===n.length?t.locale.bold.description:n.text,"**"===l.substr(n.start-2,2)&&"**"===l.substr(n.end,2)?(t.setSelection(n.start-2,n.end+2),t.replaceSelection(e),i=n.start-2):(t.replaceSelection("**"+e+"**"),i=n.start+2),t.setSelection(i,i+e.length)}},italic:{title:this.locale.italic.title,text:"italic",group:0,callback:function(t){var e,i,n=t.getSelection(),l=t.getContent();e=0===n.length?t.locale.italic.description:n.text,"_"===l.substr(n.start-1,1)&&"_"===l.substr(n.end,1)?(t.setSelection(n.start-1,n.end+1),t.replaceSelection(e),i=n.start-1):(t.replaceSelection("_"+e+"_"),i=n.start+1),t.setSelection(i,i+e.length)}},image:{title:this.locale.image.title,text:"image",group:1,callback:function(t){var i,n,l,s=t.getSelection();return i=0===s.length?t.locale.image.description:s.text,l=prompt(t.locale.image.title,"http://"),e.test(l)&&(t.replaceSelection("!["+i+"]("+l+' "'+t.locale.image.description+'")'),n=s.start+2,t.setSelection(n,n+i.length)),!1}},link:{title:this.locale.link.title,text:"link",group:1,callback:function(e){var i,n,l,s=e.getSelection();return i=0===s.length?e.locale.link.description:s.text,l=prompt(e.locale.link.title,"http://"),t.test(l)&&(e.replaceSelection("["+i+"]("+l+")"),n=s.start+1,e.setSelection(n,n+i.length)),!1}},ol:{title:this.locale.ol.title,text:"ol",group:2,callback:function(t){var e,i,n=t.getSelection();if(0===n.length)e=t.locale.ol.description,t.replaceSelection("1. "+e),i=n.start+3;else if(n.text.indexOf("\n")<0)e=n.text,t.replaceSelection("1. "+e),i=n.start+3;else{var l=[];for(var s in e=(l=n.text.split("\n"))[0],l){var r=parseInt(s)+parseInt(1);l[s]=r+". "+l[s]}t.replaceSelection("\n\n"+l.join("\n")),i=n.start+5}t.setSelection(i,i+e.length)}},ul:{title:this.locale.ul.title,text:"ul",group:2,callback:function(t){var e,i,n=t.getSelection();if(0===n.length)e=t.locale.ul.description,t.replaceSelection("- "+e),i=n.start+2;else if(n.text.indexOf("\n")<0)e=n.text,t.replaceSelection("- "+e),i=n.start+2;else{var l=[];for(var s in e=(l=n.text.split("\n"))[0],l)l[s]="- "+l[s];t.replaceSelection("\n\n"+l.join("\n")),i=n.start+4}t.setSelection(i,i+e.length)}},comment:{title:this.locale.comment.title,text:"comment",group:3,callback:function(t){var e,i,n=t.getSelection();t.getContent();if(0===n.length)e=t.locale.comment.description,t.replaceSelection("> "+e),i=n.start+2;else if(n.text.indexOf("\n")<0)e=n.text,t.replaceSelection("> "+e),i=n.start+2;else{var l=[];for(var s in e=(l=n.text.split("\n"))[0],l)l[s]="> "+l[s];t.replaceSelection("\n\n"+l.join("\n")),i=n.start+4}t.setSelection(i,i+e.length)}},code:{title:this.locale.code.title,text:"code",group:3,callback:function(t){var e,i,n=t.getSelection(),l=t.getContent();e=0===n.length?t.locale.code.description:n.text,"```\n"===l.substr(n.start-4,4)&&"\n```"===l.substr(n.end,4)?(t.setSelection(n.start-4,n.end+4),t.replaceSelection(e),i=n.start-4):"`"===l.substr(n.start-1,1)&&"`"===l.substr(n.end,1)?(t.setSelection(n.start-1,n.end+1),t.replaceSelection(e),i=n.start-1):l.indexOf("\n")>-1?(t.replaceSelection("```\n"+e+"\n```"),i=n.start+4):(t.replaceSelection("`"+e+"`"),i=n.start+1),t.setSelection(i,i+e.length)}},preview:{title:this.locale.preview.title,text:"preview",group:4,callback:function(t){var e=document.getElementById("easy-markdown"),i=document.getElementById("easy-preview");document.getElementById("easy-preview-close").removeAttribute("disabled");var n=window.markdownit();i.childNodes[1].innerHTML=n.render(t.element.value),e.classList.add("is-hidden"),i.classList.add("is-visible")}}},"bootstrap"===this.options.framework||"foundation"===this.options.framework?this[this.options.framework]():this.none()}function o(t,e,i){return this.element=e,this.options=t,this.buttons=i,this.build()}function a(t,e){return this.parent=e,this.options=t,this.locale=l({},s.locale,s.locale[t.locale]||{}),this.build()}function c(t,e){this.element=t,this.parent=t.parentNode,this.parent.innerHTML="",this.options=l({},c.defaults,e||{}),"undefined"==typeof markdownit&&(this.options.disabled.preview=!0),this.preview="off";var i=new o(this.options,this.element);t.style.width=this.options.width,this.parent.appendChild(i),function(t,e){for(var i in e)t.style[i]=e[i]}(this.parent,{position:"relative",height:i.clientHeight+"px",overflow:"hidden"}),new a(this.options,this.parent)}return r.prototype={getContent:function(){return this.element.value},findSelection:function(t){var e;if((e=this.getContent().indexOf(t))>=0&&t.length>0){var i,n=this.getSelection();return this.setSelection(e,e+t.length),i=this.getSelection(),this.setSelection(n.start,n.end),i}return null},getSelection:function(){var t=this.element;return("selectionStart"in t&&function(){var e=t.selectionEnd-t.selectionStart;return{start:t.selectionStart,end:t.selectionEnd,length:e,text:t.value.substr(t.selectionStart,e)}}||function(){return null})()},setIcons:function(t,e){if("string"==typeof this.options.icons){var i=document.createTextNode(t.title);e.appendChild(i)}else{var n=document.createElement("I");n.setAttribute("class",this.options.icons[t.text]),e.appendChild(n)}},setListener:function(t){var e=this;return t.addEventListener("click",(function(t){var i=t.target,n="I"===i.nodeName?i.parentNode:i;e.buttons[n.getAttribute("data-md")].callback(e),t.preventDefault()}),!1),t},setSelection:function(t,e){var i=this.element;return("selectionStart"in i&&function(){i.selectionStart=t,i.selectionEnd=e}||function(){return null})()},replaceSelection:function(t){var e=this.element;return("selectionStart"in e&&function(){return e.value=e.value.substr(0,e.selectionStart)+t+e.value.substr(e.selectionEnd,e.value.length),e.selectionStart=e.value.length,this})()}},r.prototype.bootstrap=function(){var t=i({0:{type:"div"},1:{type:"div",attrs:{class:"btn-group",role:"group",style:"margin:5px;"},childrenOf:0},2:{type:"div",attrs:{class:"btn-group",role:"group",style:"margin:5px;"},childrenOf:0},3:{type:"div",attrs:{class:"btn-group",role:"group",style:"margin:5px;"},childrenOf:0},4:{type:"div",attrs:{class:"btn-group",role:"group",style:"margin:5px;"},childrenOf:0},5:{type:"div",attrs:{class:"btn-group",role:"group",style:"margin:5px;"},childrenOf:0}});for(var e in this.buttons){var l=this.buttons[e];if(!0!==this.options.disabled[l.text]){var s=n("BUTTON",{class:this.options.btnClass,"data-md":l.text,title:l.title});this.setIcons(l,s),t.childNodes[l.group].appendChild(s)}}return this.setListener(t),t},r.prototype.foundation=function(){var t=n("UL",{class:"button-group",style:"margin: 0 0 10px 0;"});for(var e in this.buttons){var i=this.buttons[e];if(!0!==this.options.disabled[i.text]){var l=n("LI"),s=n("A",{class:this.options.btnClass,"data-md":i.text,title:i.title});this.setIcons(i,s),t.appendChild(l).appendChild(s)}}return this.setListener(t),t},r.prototype.none=function(){var t=document.createElement("DIV");for(var e in this.buttons){var i=this.buttons[e];if(!0!==this.options.disabled[i.text]){var l=n("BUTTON",{class:this.options.btnClass,"data-md":i.text,title:i.title});this.setIcons(i,l),t.appendChild(l)}}return this.setListener(t),t},o.prototype={build:function(){var t=new r(this.element,this.options,this.buttons),e=i({0:{type:"div",attrs:{class:"easy-markdown",id:"easy-markdown"}},1:{type:"div",attrs:{id:"easy-markdown-buttons"},childrenOf:0},2:{type:"div",attrs:{id:"easy-markdown-textarea"},childrenOf:0}});return e.childNodes[0].appendChild(t),e.childNodes[1].appendChild(this.element),e}},a.prototype={build:function(){var t=i({0:{type:"div",attrs:{class:"easy-preview",id:"easy-preview",style:"height:"+this.parent.clientHeight+"px;"}},1:{type:"div",attrs:{id:"easy-preview-buttons"},childrenOf:0},2:{type:"div",attrs:{id:"easy-preview-html",style:"overflow:auto;"},childrenOf:0},3:{type:"button",attrs:{class:this.options.btnClass,disabled:"disabled",id:"easy-preview-close"},text:this.locale.getback.title,childrenOf:1},4:{type:"HR",childrenOf:1}});this.setListener(t.childNodes[0].childNodes[0]),this.parent.appendChild(t),t.childNodes[1].style.height=this.parent.clientHeight-t.childNodes[0].clientHeight-20+"px"},setListener:function(t){return t.addEventListener("click",(function(t){var e=document.getElementById("easy-markdown"),i=document.getElementById("easy-preview");document.getElementById("easy-preview-close").setAttribute("disabled","disabled"),e.classList.remove("is-hidden"),i.classList.remove("is-visible"),t.preventDefault()}),!1),t}},s.Preview=a,s.Buttons=r,s.Skeleton=o,s.Editor=c,s.locale={bold:{title:"Bold",description:"Strong Text"},italic:{title:"Italic",description:"Emphasized text"},header:{title:"Header",description:"Heading text"},image:{title:"Image",description:"Image description"},link:{title:"Link",description:"Link description"},ol:{title:"Numbered",description:"Numbered list"},ul:{title:"Bullet",description:"Bulleted list"},comment:{title:"Comment",description:"Comment"},code:{title:"Code",description:"code text"},preview:{title:"Preview"},getback:{title:"Get back"}},c.defaults={width:"100%",btnClass:"",framework:"none",locale:"",icons:"",disabled:{bold:!1,italic:!1,header:!1,image:!1,link:!1,ol:!1,ul:!1,comment:!1,code:!1,preview:!1}},s}(); diff --git a/app/static/structure.css b/app/static/structure.css index 4c2fd86d..94d7b1dc 100644 --- a/app/static/structure.css +++ b/app/static/structure.css @@ -265,4 +265,16 @@ fieldset legend { margin-right: auto; } +.community_header { + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + border-radius: 5px; +} + +.community_header_no_background .community_icon, .community_header .community_icon { + width: 120px; + height: auto; +} + /*# sourceMappingURL=structure.css.map */ diff --git a/app/static/structure.scss b/app/static/structure.scss index 3f087f1b..44c6ac76 100644 --- a/app/static/structure.scss +++ b/app/static/structure.scss @@ -50,3 +50,17 @@ nav, etc which are used site-wide */ margin-left: auto; margin-right: auto; } + +.community_header { + background-repeat: no-repeat; + background-position: center center; + background-size: cover; + border-radius: 5px; +} + +.community_header_no_background, .community_header { + .community_icon { + width: 120px; + height: auto; + } +} diff --git a/app/static/styles.css b/app/static/styles.css index 8911f0ba..a70db224 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -247,10 +247,37 @@ nav.navbar { font-size: 140%; } -.alert-message { +.alert-info, .alert-message, .alert-error { color: #0c5460; background-color: #d1ecf1; border-color: #bee5eb; } +@media (min-width: 992px) { + .alert-info, .alert-message, .alert-error { + padding-left: 1.8rem; + padding-right: 1.8rem; + } +} + +.alert-error { + color: #722047; + background-color: #f8d7da; +} + +.community_icon { + width: 30px; + height: auto; +} + +.community_icon_big { + width: 120px; + height: auto; +} + +.bump_up { + position: absolute; + top: 104px; + left: 26px; +} /*# sourceMappingURL=styles.css.map */ diff --git a/app/static/styles.scss b/app/static/styles.scss index 7421c813..e8541325 100644 --- a/app/static/styles.scss +++ b/app/static/styles.scss @@ -45,8 +45,34 @@ nav.navbar { font-size: 140%; } -.alert-message { +.alert-info, .alert-message, .alert-error { color: #0c5460; background-color: #d1ecf1; border-color: #bee5eb; + @include breakpoint(tablet) { + padding-left: 1.8rem; + padding-right: 1.8rem; + } +} + +.alert-error { + color: #722047; + background-color: #f8d7da; +} + +.community_icon { + width: 30px; + height: auto; +} + +.community_icon_big { + width: 120px; + height: auto; +} + +.bump_up { + position: absolute; + top: 104px; + left: 26px; + } \ No newline at end of file diff --git a/app/templates/base.html b/app/templates/base.html index e5bd50b8..d96bc9e0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -32,7 +32,7 @@ {% block navbar %}