community list and beginning of viewing community

This commit is contained in:
rimu 2023-08-29 22:01:06 +12:00
parent a4f8791777
commit 82000c1095
29 changed files with 1530 additions and 22 deletions

182
INSTALL.md Normal file
View file

@ -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 <package_name>```
---
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

View file

@ -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)

View file

@ -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/<actor>/outbox', methods=['GET'])

View file

@ -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

View file

@ -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'))

View file

@ -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")

View file

@ -0,0 +1,5 @@
from flask import Blueprint
bp = Blueprint('community', __name__)
from app.community import routes

18
app/community/forms.py Normal file
View file

@ -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'))

44
app/community/routes.py Normal file
View file

@ -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 <a href="https://lemmyverse.net/communities">Lemmyverse.net</a> 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/<actor>', 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)

66
app/community/util.py Normal file
View file

@ -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

View file

@ -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

View file

@ -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)
return render_template('list_communities.html', communities=communities)
@bp.before_app_request
def before_request():
g.locale = str(get_locale())

View file

@ -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))

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="utf-8">
<title></title>
<link rel="stylesheet" href="https://netdna.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">
<link rel="stylesheet" href="https://netdna.bootstrapcdn.com/bootstrap/3.3.5/css/bootstrap.min.css">
<link href="editor.css" rel="stylesheet" type="text/css">
</head>
<body>
<header></header>
<main>
<textarea rows="10" class="form-control" id="editor"></textarea>
</div>
<script src="index.min.js"></script>
<script>
easyMarkdown(document.getElementById('editor'),{
btnClass:'btn btn-danger',
framework:'bootstrap',
icons : {
bold: 'fa fa-bold fa-fw',
italic: 'fa fa-italic fa-fw',
header: 'fa fa-header fa-fw',
image: 'fa fa-picture-o fa-fw',
link: 'fa fa-link fa-fw',
ol: 'fa fa-list-ol fa-fw',
ul: 'fa fa-list-ul fa-fw',
comment: 'fa fa-comment fa-fw',
code: 'fa fa-code fa-fw',
preview: 'fa fa-search fa-fw',
back: 'fa fa-backward fa-fw'
}
});
</script>
</main>
<footer></footer>
</body>
</html>

View file

@ -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;
}

View file

@ -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;
})();

1
app/static/js/markdown/index.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -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 */

View file

@ -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;
}
}

View file

@ -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 */

View file

@ -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;
}

View file

@ -32,7 +32,7 @@
<!-- Page content -->
{% block navbar %}
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
<div class="container-fluid">
<div class="container-lg">
<a class="navbar-brand" href="/" target="_blank">Logo</a>
<button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
@ -43,10 +43,12 @@
<ul class="nav navbar-nav ml-md-4">
{% if current_user.is_anonymous %}
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/communities">{{ _('Communities') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/auth/login">{{ _('Log in') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/auth/register">{{ _('Register') }}</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="/">{{ _('Home') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/communities">{{ _('Communities') }}</a></li>
<li class="nav-item"><a class="nav-link" href="/auth/logout">{{ _('Log out') }}</a></li>
{% endif %}
</ul>
@ -57,7 +59,7 @@
{% endblock %}
{% block content %}
<div id="outer_container" class="container-fluid flex-shrink-0 mt-4">
<div id="outer_container" class="container-lg flex-shrink-0 mt-4 pt-1">
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}

View file

@ -0,0 +1,38 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Search') }}</div>
{{ render_form(form) }}
</div>
</div>
</div>
</div>
{% if new_community %}
<div class="row">
<div class="col mx-auto">
<div class="card mt-5">
<div class="card-body p-6">
<div class="card-title">{{ _('Found a community:') }}</div>
<div class="card-body">
<p>
<a href="/c/{{ new_community.link() }}"><img src="{{ new_community.icon_image()}}" class="community_icon rounded-circle" /></a>
<a href="/c/{{ new_community.link() }}">{{ new_community.title }}@{{ new_community.ap_domain }}</a>
{% if subscribed %}
<a class="btn btn-primary mt-4" href="/c/{{ new_community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="btn btn-primary mt-4" href="/c/{{ new_community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %}
</p>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% from 'bootstrap/form.html' import render_form %}
{% block app_content %}
<div class="row">
<div class="col-8 position-relative">
{% if community.header_image() != '' %}
<div class="community_header" style="height: 240px; background-image: url({{ community.header_image() }});"></div>
<img class="community_icon_big bump_up rounded-circle" src="{{ community.icon_image() }}" />
<h1 class="mt-2">{{ community.title }}</h1>
{% else %}
<div class="row">
<div class="col-2">
<img class="community_icon_big rounded-circle" src="{{ community.icon_image() }}" />
</div>
<div class="col-10">
<h1 class="mt-3">{{ community.title }}</h1>
</div>
</div>
{% endif %}
</div>
<div class="col-4">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-6">
{% if current_user.subscribed(community) %}
<a class="w-100 btn btn-primary" href="/c/{{ community.link() }}/unsubscribe">{{ _('Unsubscribe') }}</a>
{% else %}
<a class="w-100 btn btn-primary" href="/c/{{ community.link() }}/subscribe">{{ _('Subscribe') }}</a>
{% endif %}
</div>
<div class="col-6">
<a class="w-100 btn btn-primary" href="#">{{ _('Create a post') }}</a>
</div>
</div>
<form method="get">
<input type="search" name="search" class="form-control mt-2" placeholder="Search this community" />
</form>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h2>About community</h2>
</div>
<div class="card-body">
{{ community.description|safe }}
</div>
</div>
</div>
</div>
<div class="row">
</div>
{% endblock %}

View file

@ -2,5 +2,56 @@
{% from 'bootstrap5/form.html' import render_form %}
{% block app_content %}
<p>Community list goes here.</p>
<div class="row g-2 justify-content-between">
<div class="col-auto">
<div class="btn-group">
<a href="#" class="btn btn-outline-secondary">{{ _('All') }}</a>
<a href="#" class="btn btn-outline-secondary">{{ _('Local') }}</a>
<a href="#" class="btn btn-outline-secondary">{{ _('Subscribed') }}</a>
</div>
</div>
<div class="col-auto">
<div class="btn-group">
<a href="#" class="btn btn-outline-secondary">{{ _('Add local') }}</a>
<a href="{{ url_for('community.add_remote') }}" class="btn btn-outline-secondary">{{ _('Add remote') }}</a>
</div>
<input type="search" placeholder="Find a community" class="form-control">
</div>
</div>
{% if len(communities) > 0 %}
<div class="table-responsive-md">
<table class="table table-striped table-hover w-100">
<thead>
<tr>
<th scope="col" colspan="2">{{ _('Name') }}</th>
<th scope="col">{{ _('Posts') }}</th>
<th scope="col">{{ _('Comments') }}</th>
<th scope="col">{{ _('Active') }}</th>
<th scope="col">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for community in communities %}
<tr class="">
<td><a href="/c/{{ community.link() }}"><img src="{{ community.icon_image() }}" class="community_icon rounded-circle" /></a></td>
<th scope="row"><a href="/c/{{ community.link() }}">{{ community.display_name() }}</a></th>
<td>{{ community.post_count }}</td>
<td>{{ community.post_reply_count }}</td>
<td>{{ moment(community.last_active).fromNow(refresh=True) }}</td>
<td>{% if current_user.subscribed(community) %}
<a class="btn btn-primary btn-sm" href="/c/{{ community.link() }}/unsubscribe">Unsubscribe</a>
{% else %}
<a class="btn btn-primary btn-sm" href="/c/{{ community.link() }}/subscribe">Subscribe</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p>{{ _('There are no communities yet.') }}</p>
{% endif %}
{% endblock %}

25
app/utils.py Normal file
View file

@ -0,0 +1,25 @@
import requests
import os
from flask import current_app
# ----------------------------------------------------------------------
# Jinja: when a file was modified. Useful for cache-busting
def getmtime(filename):
return os.path.getmtime('static/' + filename)
# do a GET request to a uri, return the result
def get_request(uri, params=None, headers=None) -> requests.Response:
try:
response = requests.get(uri, params=params, headers=headers, timeout=1, allow_redirects=True)
except requests.exceptions.SSLError as invalid_cert:
# Not our problem if the other end doesn't have proper SSL
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
raise requests.exceptions.RequestException(f"InvalidCodepoint: {str(ex)}") from None
return response

View file

@ -20,3 +20,4 @@ class Config(object):
RECAPTCHA3_PUBLIC_KEY = os.environ.get("RECAPTCHA3_PUBLIC_KEY")
RECAPTCHA3_PRIVATE_KEY = os.environ.get("RECAPTCHA3_PRIVATE_KEY")
MODE = os.environ.get('MODE') or 'development'
LANGUAGES = ['en']

View file

@ -0,0 +1,46 @@
"""community image
Revision ID: feef49234599
Revises: 93e4cef1698c
Create Date: 2023-08-27 19:04:44.591324
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'feef49234599'
down_revision = '93e4cef1698c'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.add_column(sa.Column('image_id', sa.Integer(), nullable=True))
batch_op.create_foreign_key(None, 'file', ['image_id'], ['id'])
with op.batch_alter_table('instance', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_instance_domain'), ['domain'], unique=False)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.create_index(batch_op.f('ix_user_verification_token'), ['verification_token'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_user_verification_token'))
with op.batch_alter_table('instance', schema=None) as batch_op:
batch_op.drop_index(batch_op.f('ix_instance_domain'))
with op.batch_alter_table('community', schema=None) as batch_op:
batch_op.drop_constraint(None, type_='foreignkey')
batch_op.drop_column('image_id')
# ### end Alembic commands ###

View file

@ -3,7 +3,9 @@
from app import create_app, db, cli
import os
import os, click
from app.utils import getmtime
app = create_app()
cli.register(app)
@ -19,3 +21,8 @@ def app_context_processor(): # NB there needs to be an identical function in cb
@app.shell_context_processor
def make_shell_context():
return {'db': db}
with app.app_context():
app.jinja_env.globals['getmtime'] = getmtime
app.jinja_env.globals['len'] = len