reskinning for tubbycats

This commit is contained in:
lza_menace
2022-03-10 15:57:16 -08:00
parent 7b50895212
commit fca8ed8176
54 changed files with 100 additions and 92 deletions
View File
+12
View File
@@ -0,0 +1,12 @@
from logging.config import dictConfig
from tubbymemes.factory import create_app
from tubbymemes import config
app = create_app()
dictConfig(config.LOGGING_CONFIG)
if __name__ == '__main__':
app.run()
View File
+126
View File
@@ -0,0 +1,126 @@
import requests
from flask import Blueprint
from secrets import token_urlsafe
from datetime import datetime
from tubbymemes.factory import db
from tubbymemes.helpers import get_eth_contract
from tubbymemes.models import Meme, User, Remote
from tubbymemes import config
bp = Blueprint('cli', 'cli', cli_group=None)
@bp.cli.command('init')
def init():
db.create_all()
@bp.cli.command('sync-avax')
def sync_avax():
"""
Synchronize your local database with the tokens minted on Avalanche blockchain.
"""
contract = get_eth_contract()
total_supply = contract.functions.totalSupply().call()
# walk backwards through all tokens at top of supply
# until you've accounted for the last known
for i in range(total_supply, 0, -1):
# first get metadata ipfs hash
deets = contract.functions.tokenMeme(i).call()
try:
meme_exists = Meme.query.filter(Meme.meta_ipfs_hash == deets[8]).first() # noqa
user_exists = User.query.filter(User.public_address == deets[6].lower()).first() # noqa
if meme_exists:
if not meme_exists.minted:
meme_exists.minted = True
db.session.commit()
print(f'[+] Marked existing meme {meme_exists.id} as minted')
else:
print(deets)
if not user_exists:
user_exists = User(
public_address=deets[4].lower()
)
db.session.add(user_exists)
db.session.commit()
user_exists.handle = f'anon{user_exists.id}-{token_urlsafe(6)}'
db.session.commit()
print(f'[+] Created user {user_exists.handle}')
res = requests.get(f'{config.IPFS_SERVER}/ipfs/{deets[5]}', timeout=30).json()
if not 'image' in res:
print('No image IPFS hash, skipping')
continue
meme_ipfs_hash = res['image'].split('ipfs://')[1]
filename = token_urlsafe(24)
print(f'[+] Downloading image hash {meme_ipfs_hash} as {filename}')
r = requests.get(f'{config.IPFS_SERVER}/ipfs/{meme_ipfs_hash}', stream=True)
with open(f'{config.DATA_FOLDER}/uploads/{filename}', 'wb') as f:
for chunk in r.iter_content(chunk_size = 16*1024):
f.write(chunk)
meme = Meme(
title=res['name'],
file_name=filename,
description=res['description'],
user_id=user_exists.id,
meta_ipfs_hash=deets[5],
meme_ipfs_hash=meme_ipfs_hash,
minted=True,
synced=True
)
db.session.add(meme)
db.session.commit()
print(f'[+] Added new meme {meme.id}')
except Exception as e:
print(e)
@bp.cli.command('sync-remotes')
def sync_remotes():
"""
Sync remote servers with local database.
"""
from pprint import pprint
remotes = Remote.query.filter(Remote.paused == False)
for remote in remotes:
print(f'[+] Fetching approved memes from remote {remote.id} ({remote.endpoint})')
deets = requests.get(remote.endpoint + '/api/v1/memes').json()
for meme_id in deets:
meme = deets[meme_id]
user = meme['user']
meme_exists = Meme.query.filter(Meme.meta_ipfs_hash == meme['meta_ipfs_hash']).first() # noqa
user_exists = User.query.filter(User.public_address == user['public_address'].lower()).first() # noqa
if not meme_exists:
if not user_exists:
user_exists = User(
public_address=user['public_address'].lower(),
bio=user['bio'],
ipfs_hash=user['ipfs_hash'],
profile_image=user['profile_image'],
website_url=user['website_url'],
wownero_address=user['wownero_address'],
handle=user['handle']
)
db.session.add(user_exists)
db.session.commit()
print(f'[+] Created user {user_exists.handle}')
print(f'[+] Downloading image hash {meme["meme_ipfs_hash"]} as {meme["file_name"]}')
r = requests.get(f'{config.IPFS_SERVER}/ipfs/{meme["meme_ipfs_hash"]}', stream=True)
with open(f'{config.DATA_FOLDER}/uploads/{meme["file_name"]}', 'wb') as f:
for chunk in r.iter_content(chunk_size = 16*1024):
f.write(chunk)
meme = Meme(
title=meme['title'],
file_name=meme['file_name'],
description=meme['description'],
user_id=user_exists.id,
meta_ipfs_hash=meme['meta_ipfs_hash'],
meme_ipfs_hash=meme['meme_ipfs_hash'],
minted=meme['minted'],
synced=True
)
db.session.add(meme)
db.session.commit()
print(f'[+] Added new meme {meme.id}')
remote.last_sync_date = datetime.utcnow()
db.session.commit()
+54
View File
@@ -0,0 +1,54 @@
import click
from flask import Blueprint
from tubbymemes.models import Moderator, User
from tubbymemes.factory import db
bp = Blueprint('mod', 'mod')
@bp.cli.command('list')
def list():
"""
List current server moderators.
"""
for mod in Moderator.query.all():
click.echo(mod.user.handle)
@bp.cli.command('add')
@click.argument('moderator_handle')
def add(moderator_handle):
"""
Add server moderators by user handle.
"""
user = User.query.filter(User.handle == moderator_handle).first()
if user:
mod = Moderator.query.filter(Moderator.user_id == user.id).first()
if mod is None:
m = Moderator(user_id=user.id)
db.session.add(m)
db.session.commit()
click.echo(f'[+] Added moderator status to `{moderator_handle}`')
else:
click.echo('[.] That is not a valid user.')
@bp.cli.command('remove')
@click.argument('moderator_handle')
def remove(moderator_handle):
"""
Remove server moderator by user handle.
"""
user = User.query.filter(User.handle == moderator_handle).first()
if user:
mod = Moderator.query.filter(Moderator.user_id == user.id).first()
if mod:
db.session.delete(mod)
db.session.commit()
click.echo(f'[-] Removed moderator status from `{moderator_handle}`') # noqa
else:
click.echo('[.] That user is not a moderator.')
else:
click.echo('[.] That is not a valid user.')
File diff suppressed because one or more lines are too long
+57
View File
@@ -0,0 +1,57 @@
from logging.config import dictConfig
from flask import Flask
from flask_login import LoginManager
from flask_sqlalchemy import SQLAlchemy
from flask_mobility import Mobility
from web3 import Web3
from tubbymemes import config
db = SQLAlchemy()
w3 = Web3(Web3.HTTPProvider(config.AVAX_RPC))
def setup_db(app: Flask, db: SQLAlchemy = db):
app.config['SQLALCHEMY_DATABASE_URI'] = f'sqlite:///{config.DATA_FOLDER}/sqlite.db' # noqa
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
db.init_app(app)
return db
def create_app_huey():
app = Flask(__name__)
db = SQLAlchemy()
dictConfig(config.LOGGING_CONFIG)
setup_db(app, db)
return app
def create_app():
app = Flask(__name__)
app.config.from_envvar('FLASK_SECRETS')
setup_db(app)
Mobility(app)
login_manager = LoginManager(app)
login_manager.login_view = 'meme.index'
login_manager.logout_view = 'meta.disconnect'
@login_manager.user_loader
def load_user(user_id):
from tubbymemes.models import User
user = User.query.get(user_id)
return user
with app.app_context():
from tubbymemes import filters
from tubbymemes.routes import api, meme, meta, user
from tubbymemes.cli import mod, cli
app.register_blueprint(filters.bp)
app.register_blueprint(api.bp)
app.register_blueprint(meme.bp)
app.register_blueprint(meta.bp)
app.register_blueprint(user.bp)
app.register_blueprint(mod.bp)
app.register_blueprint(cli.bp)
return app
+31
View File
@@ -0,0 +1,31 @@
from flask import Blueprint
from arrow import get as arrow_get
from tubbymemes import config
bp = Blueprint('filters', 'filters')
@bp.app_template_filter('shorten_address')
def shorten_address(a):
_p = a[0:6]
_s = a[-4:]
return f'{_p}...{_s}'
@bp.app_template_filter('humanize')
def humanize(d):
if not d:
return 'never'
return arrow_get(d).humanize()
@bp.app_template_filter()
def show_snowtrace(s) -> str:
"""
Return proper links to Etherscan based upon the currently configured network.
"""
if config.CONTRACT_TESTNET:
return f'https://testnet.snowtrace.io/search?f=0&q={s}'
else:
return f'https://snowtrace.io/search?f=0&q={s}'
+57
View File
@@ -0,0 +1,57 @@
import ipfsApi
from eth_account.messages import encode_defunct
from tubbymemes.models import Meme
from tubbymemes.factory import w3
from tubbymemes import config
def get_eth_contract():
"""
Return a web3 contract object with the currently
deployed tubbymemes smart contract.
"""
contract_abi = config.CONTRACT_ABI
contract_address = w3.toChecksumAddress(config.CONTRACT_ADDRESS)
return w3.eth.contract(
address=contract_address,
abi=contract_abi
)
def verify_signature(message, signature, public_address):
msg = encode_defunct(text=message)
recovered = w3.eth.account.recover_message(msg, signature=signature)
if recovered.lower() == public_address.lower():
return True
else:
return False
def upload_to_ipfs(meme_id: str):
meme = Meme.query.get(meme_id)
if not meme:
return False
try:
client = ipfsApi.Client('127.0.0.1', 5001)
artwork_hashes = client.add(meme.get_fs_path())
artwork_hash = artwork_hashes[0]['Hash']
print(f'[+] Uploaded artwork to IPFS: {artwork_hash}')
meta = {
'name': meme.title,
'description': meme.description,
'image': f'ipfs://{artwork_hash}',
'create_date': meme.create_date.strftime('%Y-%m-%d %H:%M:%S'),
'file_name': meme.file_name,
'ipfs_hash': artwork_hash,
'properties': {
'creator_handle': meme.user.handle,
'creator_address': meme.user.public_address
}
}
meta_hash = client.add_json(meta)
print(f'[+] Uploaded metadata to IPFS: {meta_hash}')
return (meta_hash, artwork_hash)
except Exception as e:
print(f'[!] Error: {e}')
return False
+136
View File
@@ -0,0 +1,136 @@
from uuid import uuid4
from datetime import datetime
from flask import url_for
from flask_login import login_user
from sqlalchemy import inspect
from tubbymemes.factory import db
from tubbymemes import config
def rand_id():
return uuid4().hex
class Moderator(db.Model):
__tablename__ = 'moderators'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
user = db.relationship('User', back_populates='moderator')
def __rep__(self):
return self.user.handle
class User(db.Model):
__tablename__ = 'users'
id = db.Column(db.Integer, primary_key=True)
register_date = db.Column(db.DateTime, default=datetime.utcnow)
last_login_date = db.Column(db.DateTime, nullable=True)
verified = db.Column(db.Boolean, default=False)
public_address = db.Column(db.String(180))
nonce = db.Column(db.String(180), default=rand_id())
nonce_date = db.Column(db.DateTime, default=datetime.utcnow)
handle = db.Column(db.String(40), unique=True)
bio = db.Column(db.String(600), nullable=True)
profile_image = db.Column(db.String(300), nullable=True)
ipfs_hash = db.Column(db.String(100), nullable=True)
wownero_address = db.Column(db.String(120), nullable=True)
website_url = db.Column(db.String(120), nullable=True)
moderator = db.relationship('Moderator', back_populates='user')
memes = db.relationship('Meme', back_populates='user')
def as_dict(self):
return {c.key: getattr(self, c.key)
for c in inspect(self).mapper.column_attrs if c.key != 'nonce'}
def __repr__(self):
return str(self.handle)
@property
def is_authenticated(self):
return True
@property
def is_active(self):
return True
@property
def is_anonymous(self):
return False
@property
def is_admin(self):
return self.admin
def is_moderator(self):
return len(self.moderator) > 0
def get_id(self):
return self.id
def get_profile_image(self, full=True):
if self.profile_image:
if full:
return url_for('meta.uploaded_file', filename=self.profile_image) # noqa
else:
return self.profile_image
else:
return '/static/img/logo.png'
def generate_nonce(self):
return rand_id()
def change_nonce(self):
self.nonce = rand_id()
self.nonce_date = datetime.utcnow()
db.session.commit()
def login(self):
self.change_nonce()
self.last_login_date = datetime.utcnow()
login_user(self)
db.session.commit()
class Meme(db.Model):
__tablename__ = 'memes'
id = db.Column(db.String(80), default=rand_id, primary_key=True)
create_date = db.Column(db.DateTime, default=datetime.utcnow)
file_name = db.Column(db.String(200), unique=True)
meta_ipfs_hash = db.Column(db.String(100), unique=True, nullable=True)
meme_ipfs_hash = db.Column(db.String(100), unique=True, nullable=True)
title = db.Column(db.String(50))
description = db.Column(db.String(400), nullable=True)
minted = db.Column(db.Boolean, default=False)
approved = db.Column(db.Boolean, default=False)
synced = db.Column(db.Boolean, default=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
user = db.relationship('User', back_populates='memes')
def as_dict(self):
return {c.key: getattr(self, c.key)
for c in inspect(self).mapper.column_attrs}
def __repr__(self):
return str(f'meme-{self.id}')
def get_fs_path(self):
return f'{config.DATA_FOLDER}/uploads/{self.file_name}'
class Remote(db.Model):
__tablename__ = 'remotes'
id = db.Column(db.Integer, primary_key=True)
create_date = db.Column(db.DateTime, default=datetime.utcnow)
last_sync_date = db.Column(db.DateTime, nullable=True)
paused = db.Column(db.Boolean, default=False)
endpoint = db.Column(db.String(120))
def __repr__(self):
return str(f'remote-{self.id}')
View File
+152
View File
@@ -0,0 +1,152 @@
from secrets import token_urlsafe
from flask import Blueprint, request, jsonify
from flask_login import current_user
from tubbymemes.factory import db
from tubbymemes.helpers import verify_signature
from tubbymemes.models import User, Meme
bp = Blueprint('api', 'api', url_prefix='/api/v1')
@bp.route('/memes')
def memes():
"""
List all the approved memes on the server to allow remote syncing.
"""
all_memes = dict()
memes = Meme.query.filter(Meme.approved == True).all()
for meme in memes:
all_memes[meme.id] = meme.as_dict()
all_memes[meme.id]['user'] = meme.user.as_dict()
return jsonify(all_memes)
@bp.route('/user_exists')
def user_exists():
"""
Check to see if a given user exists (handle or wallet address).
This logic will help the login/connect MetaMask flow.
"""
if 'public_address' in request.args:
query_str = 'public_address'
query_field = User.public_address
elif 'handle' in request.args:
query_str = 'handle'
query_field = User.handle
else:
return jsonify({'success': False})
u = User.query.filter(
query_field == request.args[query_str].lower()
).first()
if u:
nonce = u.nonce
else:
nonce = User().generate_nonce()
return jsonify({
'user_exists': u is not None,
'nonce': nonce,
'query': query_str,
'success': True
})
@bp.route('/update/user', methods=['POST'])
def update_user():
if not current_user.is_authenticated:
return jsonify({
'success': False,
'message': 'Must be authenticated in order to update.'
})
data = request.get_json()
_u = User.query.get(int(data['user_id']))
if _u:
if current_user.id == _u.id:
user_exists = User.query.filter(
User.handle == data['handle']
).first()
if user_exists and not current_user.id == user_exists.id:
return jsonify({
'success': False,
'message': 'That user handle already exists on this server'
})
_u.wownero_address = data['wownero_address']
_u.ipfs_hash = data['ipfs_hash']
_u.handle = data['handle']
db.session.commit()
return jsonify({
'success': True,
'message': 'Updated user record.'
})
else:
return jsonify({
'success': False,
'message': 'Cannot edit another record.'
})
else:
return jsonify({
'success': False,
'message': 'User does not exist.'
})
@bp.route('/authenticate/metamask', methods=['POST'])
def authenticate_metamask():
"""
This is the login/authenticate route for this dApp.
Users POST a `signedData` blob, a message signed by the user with MetaMask
(`personal_sign` method).
This route will verify the signed data against the user's public ETH
address. If no user exists, they get an entry in the database with a
default handle assigned. If user does exist, they get logged in.
"""
data = request.get_json()
if current_user.is_authenticated:
return jsonify({
'success': False,
'message': 'Already registered and authenticated.'
})
_u = User.query.filter_by(
public_address=data['public_address'].lower()
).first()
if _u:
if data['message'].endswith(_u.nonce):
if verify_signature(data['message'], data['signed_data'], data['public_address']): # noqa
_u.login()
return jsonify({
'success': True,
'message': 'Logged in'
})
else:
return jsonify({
'success': False,
'message': 'Invalid signature'
})
else:
return jsonify({
'success': False,
'message': 'Invalid nonce in signed message'
})
else:
rand_str = token_urlsafe(6)
user = User(
public_address=data['public_address'].lower()
)
db.session.add(user)
db.session.commit()
user.handle = f'anon{user.id}-{rand_str}'
db.session.commit()
user.login()
return jsonify({
'success': True,
'message': 'Registered'
})
+158
View File
@@ -0,0 +1,158 @@
from os import path, remove
from secrets import token_urlsafe
import ipfsApi
from flask import Blueprint, render_template, request
from flask import redirect, flash, url_for
from flask_login import current_user
from web3 import Web3
from tubbymemes.models import Meme
from tubbymemes.helpers import upload_to_ipfs
from tubbymemes.factory import db
from tubbymemes import config
bp = Blueprint('meme', 'meme')
@bp.route('/')
def index():
memes = Meme.query.filter(
Meme.approved == True
).order_by(Meme.create_date.desc())
return render_template('index.html', memes=memes)
@bp.route('/mod')
def mod():
if not current_user.is_authenticated:
flash('You must be logged in and have MetaMask wallet connected.', 'warning')
return redirect(url_for('meme.index'))
if not current_user.is_moderator():
flash('You are not a moderator.', 'warning')
return redirect(url_for('meme.index'))
memes = Meme.query.filter(
Meme.approved != True
).order_by(Meme.create_date.asc())
return render_template('index.html', memes=memes)
@bp.route('/publish', methods=['GET', 'POST'])
def publish():
if not current_user.is_authenticated:
flash('You need to connect your wallet first.', 'warning')
return redirect(url_for('meme.index'))
if not current_user.wownero_address:
flash('You need to specify your Wownero wallet address first.', 'warning')
return redirect(url_for('user.show', handle=current_user.handle))
meme = None
try:
client = ipfsApi.Client('127.0.0.1', 5001)
client.add_json({})
except Exception as e:
msg = f'[!] IPFS Error: {e}'
print(msg)
flash(msg, 'error')
if "file" in request.files:
return '<script>window.history.back()</script>'
return redirect(url_for('meme.index') + '?ipfs_error=1')
if "file" in request.files:
title = request.form.get('title')
description = request.form.get('description')
file = request.files["file"]
filename = "{}{}".format(
token_urlsafe(24),
path.splitext(file.filename)[1]
)
full_path = f'{config.DATA_FOLDER}/uploads/{filename}'
file.save(full_path)
try:
meme = Meme(
file_name=filename,
title=title,
description=description,
user_id=current_user.id
)
db.session.add(meme)
db.session.commit()
if current_user.verified or current_user.is_moderator():
res = upload_to_ipfs(meme.id)
meme.meta_ipfs_hash = res[0]
meme.meme_ipfs_hash = res[1]
meme.approved = True
db.session.commit()
flash('Published new meme to local database and IPFS.', 'success') # noqa
else:
flash('Published new meme to database for review by moderators.', 'success') # noqa
return redirect(url_for('meme.index'))
except ConnectionError:
flash('[!] Unable to connect to local ipfs', 'error')
except Exception as e:
print(e)
return render_template(
'publish.html',
meme=meme
)
@bp.route('/meme/<meme_id>')
def show(meme_id):
meme = Meme.query.filter(Meme.id == meme_id).first()
if not meme:
return redirect('/')
if not meme.approved and not current_user.is_authenticated:
flash('You need to be logged in to view that meme.', 'warning')
return redirect(url_for('meme.index'))
elif not meme.approved and not current_user.is_moderator():
flash('You need to be a moderator to view that meme.', 'warning')
return redirect(url_for('meme.index'))
return render_template('meme.html', meme=meme)
@bp.route('/meme/<meme_id>/<action>')
def approve(meme_id, action):
if not current_user.is_authenticated:
flash('You need to be logged in to reach this page.', 'warning')
return redirect(url_for('meme.index'))
if not current_user.is_moderator():
flash('You need to be a moderator to reach this page.', 'warning')
return redirect(url_for('meme.index'))
meme = Meme.query.get(meme_id)
if not meme:
flash('That meme does not exist.', 'warning')
return redirect(url_for('meme.index'))
if meme.approved is True:
flash('That meme already has been approved.', 'warning')
return redirect(url_for('meme.show', meme_id=meme.id))
if action == 'approve':
res = upload_to_ipfs(meme.id)
if not res:
flash('Unable to post to IPFS, daemon may be offline.', 'error')
return redirect(url_for('meme.show', meme_id=meme.id))
existing_meta_ipfs = Meme.query.filter(
Meme.meta_ipfs_hash == res[0]
).first()
existing_meme_ipfs = Meme.query.filter(
Meme.meme_ipfs_hash == res[1]
).first()
if meme.synced is False:
if existing_meta_ipfs or existing_meme_ipfs:
flash('Cannot use an existing IPFS hash for either metadata or memes on new posts.', 'warning') # noqa
return redirect(url_for('meme.show', meme_id=meme.id))
meme.meta_ipfs_hash = res[0]
meme.meme_ipfs_hash = res[1]
meme.approved = True
db.session.commit()
flash('Approved meme and published new meme to local IPFS server.', 'success')
elif action == 'deny':
# delete image
# delete from database
if path.exists(meme.get_fs_path()):
remove(meme.get_fs_path())
db.session.delete(meme)
db.session.commit()
flash('Deleted image and removed meme from database.', 'success')
else:
flash('Unknown action.', 'warning')
return redirect(url_for('meme.mod'))
+65
View File
@@ -0,0 +1,65 @@
from flask import Blueprint, render_template, send_from_directory
from flask import redirect, url_for, flash, request
from flask_login import logout_user, current_user
from tubbymemes.models import User, Remote
from tubbymemes.factory import db
from tubbymemes import config
bp = Blueprint('meta', 'meta')
@bp.route('/uploads/<path:filename>')
def uploaded_file(filename):
"""
Retrieve an uploaded file from uploads directory.
"""
return send_from_directory(f'{config.DATA_FOLDER}/uploads', filename)
@bp.route('/about')
def about():
return render_template('about.html')
@bp.route('/disconnect')
def disconnect():
logout_user()
return redirect(url_for('meme.index'))
@bp.route('/remotes', methods=['GET', 'POST'])
def remotes():
if not current_user.is_authenticated or not current_user.is_moderator():
flash('You need to be moderator to access that page.', 'warning')
return redirect(url_for('meme.index'))
remotes = Remote.query.filter().order_by(Remote.create_date.asc())
ep = request.form.get('endpoint')
if ep:
if ep.startswith('http'):
if not Remote.query.filter(Remote.endpoint == ep).first():
r = Remote(endpoint=ep)
db.session.add(r)
db.session.commit()
flash('Added new remote server.', 'success')
else:
flash('That remote server already exists.', 'warning')
else:
flash('Incorrect endpoint provided, must start with http or https.', 'warning') # noqa
return render_template('remotes.html', remotes=remotes)
@bp.route('/remotes/delete/<int:remote_id>')
def delete_remote(remote_id):
existing = Remote.query.get(remote_id)
if not current_user.is_authenticated or not current_user.is_moderator():
flash('You need to be moderator to access that page.', 'warning')
return redirect(url_for('meme.index'))
if not existing:
flash('That remote does not exist', 'warning')
return redirect(url_for('meme.mod'))
db.session.delete(existing)
db.session.commit()
flash(f'Deleted remote server {existing}', 'success')
return redirect(url_for('meta.remotes'))
+15
View File
@@ -0,0 +1,15 @@
from flask import Blueprint, render_template
from flask import redirect, url_for
from tubbymemes.models import User
bp = Blueprint('user', 'user')
@bp.route('/user/<handle>')
def show(handle):
user = User.query.filter(User.handle == handle).first()
if not user:
return redirect(url_for('meme.index'))
return render_template('profile.html', user=user)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+34
View File
@@ -0,0 +1,34 @@
.screen {
padding-bottom: 1em;
max-width: 500px;
margin: 0 auto;
}
.card {
margin: 1em;
padding: 1em;
width: 300px;
border-radius: 6px;
}
.image {
text-align: center;
}
.image img, .image video {
border-radius: 6px;
}
.img-fluid {
max-height: 200px;
object-fit: cover;
}
.wrap {
word-wrap: break-word;
}
.mintedAVAX {
background-color: rgba(242,37,52,.2);
border: 2px solid rgba(243,37,52,1);
}
+46
View File
@@ -0,0 +1,46 @@
.noty_theme__relax.noty_bar {
margin: 4px 0;
overflow: hidden;
border-radius: 2px;
position: relative; }
.noty_theme__relax.noty_bar .noty_body {
padding: 10px; }
.noty_theme__relax.noty_bar .noty_buttons {
border-top: 1px solid #e7e7e7;
padding: 5px 10px; }
.noty_theme__relax.noty_type__alert,
.noty_theme__relax.noty_type__notification {
background-color: #fff;
border: 1px solid #dedede;
color: #444; }
.noty_theme__relax.noty_type__warning {
background-color: #FFEAA8;
border: 1px solid #FFC237;
color: #826200; }
.noty_theme__relax.noty_type__warning .noty_buttons {
border-color: #dfaa30; }
.noty_theme__relax.noty_type__error {
background-color: #FF8181;
border: 1px solid #e25353;
color: #FFF; }
.noty_theme__relax.noty_type__error .noty_buttons {
border-color: darkred; }
.noty_theme__relax.noty_type__info,
.noty_theme__relax.noty_type__information {
background-color: #78C5E7;
border: 1px solid #3badd6;
color: #FFF; }
.noty_theme__relax.noty_type__info .noty_buttons,
.noty_theme__relax.noty_type__information .noty_buttons {
border-color: #0B90C4; }
.noty_theme__relax.noty_type__success {
background-color: #BCF5BC;
border: 1px solid #7cdd77;
color: darkgreen; }
.noty_theme__relax.noty_type__success .noty_buttons {
border-color: #50C24E; }
+222
View File
@@ -0,0 +1,222 @@
.noty_layout_mixin, #noty_layout__top, #noty_layout__topLeft, #noty_layout__topCenter, #noty_layout__topRight, #noty_layout__bottom, #noty_layout__bottomLeft, #noty_layout__bottomCenter, #noty_layout__bottomRight, #noty_layout__center, #noty_layout__centerLeft, #noty_layout__centerRight {
position: fixed;
margin: 0;
padding: 0;
z-index: 9999999;
-webkit-transform: translateZ(0) scale(1, 1);
transform: translateZ(0) scale(1, 1);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
-webkit-font-smoothing: subpixel-antialiased;
filter: blur(0);
-webkit-filter: blur(0);
max-width: 90%; }
#noty_layout__top {
top: 0;
left: 5%;
width: 90%; }
#noty_layout__topLeft {
top: 20px;
left: 20px;
width: 325px; }
#noty_layout__topCenter {
top: 5%;
left: 50%;
width: 325px;
-webkit-transform: translate(-webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
transform: translate(calc(-50% - .5px)) translateZ(0) scale(1, 1); }
#noty_layout__topRight {
top: 20px;
right: 20px;
width: 325px; }
#noty_layout__bottom {
bottom: 0;
left: 5%;
width: 90%; }
#noty_layout__bottomLeft {
bottom: 20px;
left: 20px;
width: 325px; }
#noty_layout__bottomCenter {
bottom: 5%;
left: 50%;
width: 325px;
-webkit-transform: translate(-webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
transform: translate(calc(-50% - .5px)) translateZ(0) scale(1, 1); }
#noty_layout__bottomRight {
bottom: 20px;
right: 20px;
width: 325px; }
#noty_layout__center {
top: 50%;
left: 50%;
width: 325px;
-webkit-transform: translate(-webkit-calc(-50% - .5px), -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
transform: translate(calc(-50% - .5px), calc(-50% - .5px)) translateZ(0) scale(1, 1); }
#noty_layout__centerLeft {
top: 50%;
left: 20px;
width: 325px;
-webkit-transform: translate(0, -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
transform: translate(0, calc(-50% - .5px)) translateZ(0) scale(1, 1); }
#noty_layout__centerRight {
top: 50%;
right: 20px;
width: 325px;
-webkit-transform: translate(0, -webkit-calc(-50% - .5px)) translateZ(0) scale(1, 1);
transform: translate(0, calc(-50% - .5px)) translateZ(0) scale(1, 1); }
.noty_progressbar {
display: none; }
.noty_has_timeout.noty_has_progressbar .noty_progressbar {
display: block;
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 100%;
background-color: #646464;
opacity: 0.2;
filter: alpha(opacity=10); }
.noty_bar {
-webkit-backface-visibility: hidden;
-webkit-transform: translate(0, 0) translateZ(0) scale(1, 1);
-ms-transform: translate(0, 0) scale(1, 1);
transform: translate(0, 0) scale(1, 1);
-webkit-font-smoothing: subpixel-antialiased;
overflow: hidden; }
.noty_effects_open {
opacity: 0;
-webkit-transform: translate(50%);
-ms-transform: translate(50%);
transform: translate(50%);
-webkit-animation: noty_anim_in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
animation: noty_anim_in 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards; }
.noty_effects_close {
-webkit-animation: noty_anim_out 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
animation: noty_anim_out 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55);
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards; }
.noty_fix_effects_height {
-webkit-animation: noty_anim_height 75ms ease-out;
animation: noty_anim_height 75ms ease-out; }
.noty_close_with_click {
cursor: pointer; }
.noty_close_button {
position: absolute;
top: 2px;
right: 2px;
font-weight: bold;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
background-color: rgba(0, 0, 0, 0.05);
border-radius: 2px;
cursor: pointer;
-webkit-transition: all .2s ease-out;
transition: all .2s ease-out; }
.noty_close_button:hover {
background-color: rgba(0, 0, 0, 0.1); }
.noty_modal {
position: fixed;
width: 100%;
height: 100%;
background-color: #000;
z-index: 10000;
opacity: .3;
left: 0;
top: 0; }
.noty_modal.noty_modal_open {
opacity: 0;
-webkit-animation: noty_modal_in .3s ease-out;
animation: noty_modal_in .3s ease-out; }
.noty_modal.noty_modal_close {
-webkit-animation: noty_modal_out .3s ease-out;
animation: noty_modal_out .3s ease-out;
-webkit-animation-fill-mode: forwards;
animation-fill-mode: forwards; }
@-webkit-keyframes noty_modal_in {
100% {
opacity: .3; } }
@keyframes noty_modal_in {
100% {
opacity: .3; } }
@-webkit-keyframes noty_modal_out {
100% {
opacity: 0; } }
@keyframes noty_modal_out {
100% {
opacity: 0; } }
@keyframes noty_modal_out {
100% {
opacity: 0; } }
@-webkit-keyframes noty_anim_in {
100% {
-webkit-transform: translate(0);
transform: translate(0);
opacity: 1; } }
@keyframes noty_anim_in {
100% {
-webkit-transform: translate(0);
transform: translate(0);
opacity: 1; } }
@-webkit-keyframes noty_anim_out {
100% {
-webkit-transform: translate(50%);
transform: translate(50%);
opacity: 0; } }
@keyframes noty_anim_out {
100% {
-webkit-transform: translate(50%);
transform: translate(50%);
opacity: 0; } }
@-webkit-keyframes noty_anim_height {
100% {
height: 0; } }
@keyframes noty_anim_height {
100% {
height: 0; } }
/*# sourceMappingURL=noty.css.map*/
/* Custom */
.noty_body {
text-align: center;
}
+10
View File
@@ -0,0 +1,10 @@
up-wrapper{display:inline-block}
up-bounds{position:absolute}.up-focusable-content:focus,.up-focusable-content:focus-visible{outline:0}
.up-request-loader{display:none}up-progress-bar{position:fixed;top:0;left:0;z-index:999999999;height:3px;background-color:#007bff}
up-focus-trap{position:fixed;top:0;left:0;width:0;height:0}up-modal,up-drawer,up-cover,up-modal-backdrop,up-drawer-backdrop,up-modal-viewport,up-drawer-viewport,up-cover-viewport{top:0;left:0;bottom:0;right:0}up-modal-box,up-drawer-box{box-shadow:0 0 10px 1px rgba(0,0,0,0.3)}up-popup{box-shadow:0 0 4px rgba(0,0,0,0.3)}up-modal:focus,up-drawer:focus,up-cover:focus,up-modal-box:focus,up-drawer-box:focus,up-cover-box:focus,up-popup:focus,up-modal:focus-visible,up-drawer:focus-visible,up-cover:focus-visible,up-modal-box:focus-visible,up-drawer-box:focus-visible,up-cover-box:focus-visible,up-popup:focus-visible{outline:none}up-modal,up-drawer,up-cover{z-index:2000;position:fixed}up-modal-backdrop,up-drawer-backdrop{position:absolute;background:rgba(0,0,0,0.4)}up-modal-viewport,up-drawer-viewport,up-cover-viewport{position:absolute;overflow-y:scroll;overflow-x:hidden;overscroll-behavior:contain;text-align:center}up-modal-box,up-drawer-box,up-cover-box,up-popup{display:inline-block;text-align:left;position:relative;box-sizing:border-box;max-width:100%;background-color:#fff;padding:20px;overflow-x:hidden}up-modal-content,up-drawer-content,up-cover-content,up-popup-content{display:block}up-popup{z-index:1000}up-modal-dismiss,up-drawer-dismiss,up-cover-dismiss,up-popup-dismiss{color:#888;position:absolute;top:10px;right:10px;font-size:1.7rem;line-height:0.5}up-modal-viewport{justify-content:center}up-modal[nesting="0"] up-modal-viewport{padding:25px 15px}up-modal[nesting="1"] up-modal-viewport{padding:50px 30px}up-modal[nesting="2"] up-modal-viewport{padding:75px 45px}up-modal[nesting="3"] up-modal-viewport{padding:100px 60px}up-modal[nesting="4"] up-modal-viewport{padding:125px 75px}up-modal[size=small] up-modal-box{width:350px}up-modal[size=medium] up-modal-box{width:650px}up-modal[size=large] up-modal-box{width:1000px}up-modal[size=grow] up-modal-box{width:auto}up-modal[size=full] up-modal-box{width:100%}up-drawer-viewport{text-align:left}up-drawer[position=right] up-drawer-viewport{text-align:right}up-drawer-box{min-height:100vh}up-drawer[size=small] up-drawer-box{width:150px}up-drawer[size=medium] up-drawer-box{width:340px}up-drawer[size=large] up-drawer-box{width:600px}up-drawer[size=grow] up-drawer-box{width:auto}up-drawer[size=full] up-drawer-box{width:100%}up-cover-box{width:100%;min-height:100vh;padding:0}up-popup{padding:15px}up-popup[size=small]{width:180px}up-popup[size=medium]{width:300px}up-popup[size=large]{width:550px}up-popup[size=grow] up-popup{width:auto}up-popup[size=full] up-popup{width:100%}
[up-href],[up-clickable]{cursor:pointer}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+10
View File
@@ -0,0 +1,10 @@
async function notif(s, t) {
new Noty({
type: t,
theme: 'relax',
layout: 'topCenter',
text: s,
timeout: 4500
}).show();
return
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
View File
+12
View File
@@ -0,0 +1,12 @@
from huey import RedisHuey
from tubbymemes.factory import create_app_huey
from tubbymemes import config
huey = RedisHuey(
host=config.CACHE_HOST,
port=config.CACHE_PORT
)
app = create_app_huey()
+48
View File
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
<div class="columns">
<div class="column screen" style="padding-top:1em;">
<p>
Welcome. </br>
</br>
This service is a continuation of <a href="https://suchwow.xyz">SuchWow</a> for the <a href="https://wownero.org">Wownero</a> cryptocurrency project.
It has been revamped to use <a href="https://ipfs.io">Interplanetary Filesystem</a>, server federation / content sharing, and optionally <a href="https://www.avax.network/">Avalanche network</a> via a smart contract with some useful features (if you're into memes). It's a decentralized application (dApp); it's preferred you run it locally and bootstrap the services on your own machine. Here's the source code and instructions to run: <a href="https://github.com/lalanza808/tubbymemes/" target="_blank">tubbymemes</a>
</p>
</br>
<p>
Up until now there has been a small group of people who were responsible for reviewing and approving the 4600+ memes submitted to the prior platform.
Wownero has become a meme powerhouse via SuchWow, but it's a highly centralized, aging, and fragile service.
</p>
</br>
<p>
This iteration of SuchWow will strive to provide decentralized and leaderless operations.
You can run it anywhere and see all the content, you won't need my server (but you can use it).
You should run your own.
You can control which content you see, store, and share (via IPFS pins).
I'll deploy an immutable smart contract with some tipping and storage features,
everyone can make their own clients (you can use mine, it's Flask/Python),
we make some documentation for you fucks to read (lol),
then memers can run it and go to the races; the only limit is their own hard drives.
</p>
</br>
<div class="columns">
<div class="column">
<a class="button is-info" onclick="addWOWX()">Add WOWX Tokens</a>
</div>
<div class="column">
<a class="button is-info" onclick="addAvalancheNetwork()">Add Avalanche Network</a>
</div>
</div>
</div>
</div>
</div>
</section>
{% include 'includes/footer.html' %}
</body>
</html>
@@ -0,0 +1,11 @@
<up-modal size="medium"> <!-- container with attributes -->
<up-modal-backdrop></up-modal-backdrop> <!-- semi-transparent background -->
<up-modal-viewport> <!-- scroll bar -->
<up-modal-box> <!-- white box with padding -->
<up-modal-content>
<p>Wownero Address: <span id="wowneroAddress">?</span></p>
</up-modal-content> <!-- content parent (unstyled) -->
<up-modal-dismiss>×</up-modal-dismiss> <!-- dismiss icon -->
</up-modal-box>
</up-modal-viewport>
</up-modal>
@@ -0,0 +1,6 @@
<footer style="margin: 0 auto; width: 100%; text-align: center;">
<p>Made by <a href="https://lzahq.tech" target="_blank">@lza_menace</a></p>
</footer>
{% include 'includes/scripts.html' %}
+23
View File
@@ -0,0 +1,23 @@
<head>
<title>tubbymemes Interplanetary Memes</title>
<meta property="og:description" content="Interplanetary meme platform powered by IPFS, Wownero, Avalanche, and degeneracy.">
<meta property="og:image" content="https://tubbymemes.xyz/static/img/logo.png">
<meta property="og:title" content="tubbymemes">
<meta name="twitter:card" content="summary">
<meta name="twitter:site" content="@lza_menace">
<meta name="twitter:title" content="tubbymemes">
<meta name="twitter:description" content="Interplanetary meme platform powered by IPFS, Wownero, Avalanche, and degeneracy.">
<meta name="twitter:creator" content="@lza_menace">
<meta name="twitter:image" content="https://tubbymemes.xyz/static/img/logo.png">
<meta name="twitter:domain" content="https://tubbymemes.xyz">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="/static/js/vendor/unpoly-2.5.0.min.js"></script>
<link rel="stylesheet" href="/static/css/vendor/unpoly-2.5.0.min.css">
<link rel="stylesheet" href="/static/css/bulma.min.css">
<!-- <link rel="stylesheet" href="/static/css/bulma.css.map"> -->
<link rel="stylesheet" href="/static/css/vendor/noty-relax.css" />
<link rel="stylesheet" href="/static/css/vendor/noty.css" />
<link rel="stylesheet" href="/static/css/custom.css" />
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
</head>
@@ -0,0 +1,53 @@
{# expects meme var #}
<div class="card {% if meme.minted %}mintedAVAX{% endif %}">
<div class="card-image">
<figure class="image">
<a href="{{ url_for('meme.show', meme_id=meme.id) }}">
{% if meme.file_name %}
{% if meme.file_name.endswith('mp4') %}
<video class="img-fluid" {% if not request.MOBILE %}autoplay{% else %}controls{% endif %} muted loop>
<source src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" type="video/mp4">
Your browser does not support the video tag.
</video>
{% else %}
<img alt="{{ meme.title }}" src="{{ url_for('meta.uploaded_file', filename=meme.file_name) }}" width="200px" class="img-fluid" style="" />
{% endif %}
{% else %}
<img alt="{{ meme.title }}" src="{{ config.IPFS_SERVER }}/ipfs/{{ meme.meme_ipfs_hash }}" width="200px" class="img-fluid" style="" />
{% endif %}
</a>
</figure>
</div>
<div class="card-content">
<div class="media">
<div class="media-left">
{% if meme.user.profile_image %}
<figure class="image is-48x48">
<img src="{{ url_for('meta.uploaded_file', filename=meme.user.profile_image) }}" alt="{{ meme.user.handle }} pfp">
</figure>
{% endif %}
</div>
<div class="media-content">
<p class="title is-4">{{ meme.title }}</p>
<p class="subtitle is-6">
<a href="{{ url_for('user.show', handle=meme.user.handle) }}">{{ meme.user.handle }}</a>
<a href="{{ meme.user.public_address | show_snowtrace }}" target="_blank">({{ meme.user.public_address | shorten_address }})</a>
</p>
</div>
</div>
<div class="content">
<time datetime="2016-1-1">{{ meme.create_date.strftime('%H:%M UTC - %d %b %Y') }}</time>
{% if not meme.approved %}
<div class="columns mt-2">
<div class="column">
<a class="button is-success" href="{{ url_for('meme.approve', meme_id=meme.id, action='approve') }}">Approve</a>
</div>
<div class="column">
<a class="button is-danger" href="{{ url_for('meme.approve', meme_id=meme.id, action='deny') }}">Deny</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
+30
View File
@@ -0,0 +1,30 @@
<div class="screen">
<h1 class="title">
tubbymemes.
</h1>
<p class="subtitle">
Memes. <strong>Interplanetary</strong>!
</p>
{% if request.path == '/' %}
<a class="button is-secondary" href="{{ url_for('meta.about') }}" up-target=".container">About</a>
{% if current_user.is_authenticated %}
<a class="button is-secondary" href="{{ url_for('user.show', handle=current_user.handle) }}">Profile</a>
<a class="button is-danger" href="{{ url_for('meta.disconnect') }}">Disconnect</a>
{% if current_user.is_moderator() %}
<a class="button is-warning" href="{{ url_for('meme.mod') }}" up-preload up-follow=".container">Mod</a>
{% endif %}
<a class="button is-primary" href="{{ url_for('meme.publish') }}">New Meme</a>
{% else %}
<a class="button is-primary" id="metamaskConnect" onclick="onboardMetaMask();">Connect</a>
{% endif %}
{% else %}
<a class="button" href="{{ url_for('meme.index') }}" up-preload up-follow=".container">Go Home</a>
{% endif %}
{% if request.path == '/remotes' %}
<a class="button is-warning" href="{{ url_for('meme.mod') }}" up-preload up-follow=".container">Mod</a>
{% endif %}
{% if request.path == '/mod' %}
<a class="button is-warning" href="{{ url_for('meta.remotes') }}" up-preload up-follow=".container">Remotes</a>
{% endif %}
</div>
@@ -0,0 +1,21 @@
<script src="/static/js/vendor/metamask-onboarding-1.0.1.bundle.js"></script>
<script src="/static/js/vendor/noty-3.2.0.js"></script>
<script src="/static/js/main.js"></script>
{% include 'includes/web3.html' %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
<script type="text/javascript">
{% for category, message in messages %}
{% if category == None %}{% set category = 'info' %}{% endif %}
new Noty({
type: '{{ category }}',
theme: 'relax',
layout: 'topCenter',
text: '{{ message }}',
timeout: 4500
}).show();
{% endfor %}
</script>
{% endif %}
{% endwith %}
+206
View File
@@ -0,0 +1,206 @@
<script src="/static/js/vendor/web3-1.3.6.min.js"></script>
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', () => {
// Arm connect button
onboardMetaMask();
// Detect account changes in MetaMask
if (MetaMaskOnboarding.isMetaMaskInstalled()) {
window.ethereum.on('accountsChanged', (newAccounts) => {
window.location.href = '/disconnect';
});
}
})
// Web3 and smart contract details
let contractABI = {{ config.CONTRACT_ABI | tojson }};
let contractAddress = "{{ config.CONTRACT_ADDRESS }}";
let wowxABI = {{ config.WOWX_ABI | tojson }};
let wowxAddress = "{{ config.WOWX_ADDRESS }}";
const w3 = new Web3(Web3.givenProvider);
const contract = new w3.eth.Contract(contractABI, contractAddress);
const wowxContract = new w3.eth.Contract(wowxABI, wowxAddress);
async function getMetamaskAccount() {
const accounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
const account = accounts[0];
return account
}
async function getSignedData(publicAddress, jsonData) {
const signedData = await window.ethereum.request({
method: 'eth_signTypedData_v3',
params: [publicAddress, JSON.stringify(jsonData)]
});
console.log(signedData);
return signedData
}
async function onboardMetaMask(){
const onboarding = new MetaMaskOnboarding();
const onboardButton = document.getElementById('metamaskConnect');
let accounts;
let nonce = 0;
if (!onboardButton) {
return false;
}
onboardButton.onclick = async () => {
if (!MetaMaskOnboarding.isMetaMaskInstalled()) {
onboardButton.onclick = () => {
onboardButton.classList.add('is-loading');
onboardButton.disabled = true;
onboarding.startOnboarding();
};
} else if (accounts && accounts.length > 0) {
onboardButton.classList.remove('is-loading');
onboardButton.disabled = false;
onboarding.stopOnboarding();
} else {
try {
onboardButton.classList.add('is-loading');
onboardButton.disabled = true;
let userExists;
await addAvalancheNetwork();
const allAccounts = await window.ethereum.request({
method: 'eth_requestAccounts',
});
await fetch('/api/v1/user_exists?public_address=' + allAccounts[0])
.then((resp) => resp.json())
.then(function(data) {
if (!data['success']) {
console.log('error checking user_exists!')
return
}
console.log(data);
nonce = data['nonce'];
})
const msg = 'Authentication request from tubbymemes app! Verifying message with nonce ' + nonce
const signedData = await window.ethereum.request({
method: 'personal_sign',
params: [msg, allAccounts[0]]
});
console.log(`Signing data with msg "${msg}", address "${allAccounts[0]}", signed data: ${signedData}`)
await fetch('/api/v1/authenticate/metamask', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify({
'signed_data': signedData,
'public_address': allAccounts[0],
'nonce': nonce,
'message': msg,
})
})
.then((resp) => resp.json())
.then(function(data) {
console.log(data);
if (data['success']) {
window.location.href = '/';
}
})
} catch(e) {
console.log(e);
onboardButton.classList.remove('is-loading');
}
}
};
// connectButton();
};
async function addWOWX() {
let tokenAddress = '{{ config.WOWX_ADDRESS }}';
try {
await ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: tokenAddress.toLowerCase(),
symbol: 'WOWX',
decimals: 18,
image: 'https://github.com/wownerox/meta/raw/main/logo.png'
}
},
});
} catch(e) {
console.log(e);
}
}
async function addAvalancheNetwork(){
let chainId, rpcUrl, explorerUrl, name;
if ('{{ config.CONTRACT_TESTNET }}' == 'True') {
name = 'Avalanche Testnet';
chainId = '0xA869';
rpcUrl = 'https://api.avax-test.network/ext/bc/C/rpc';
explorerUrl = 'https://testnet.snowtrace.io';
} else {
name = 'Avalanche Mainnet';
chainId = '0xA86A';
rpcUrl = 'https://api.avax.network/ext/bc/C/rpc';
explorerUrl = 'https://snowtrace.io';
}
try {
await ethereum.request({
method: 'wallet_switchEthereumChain',
params: [{ chainId: chainId }],
});
} catch (switchError) {
// This error code indicates that the chain has not been added to MetaMask.
if (switchError.code === 4902) {
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [{
chainId: chainId,
chainName: name,
nativeCurrency: {
name: 'Avalanche',
symbol: 'AVAX',
decimals: 18,
},
rpcUrls: [rpcUrl],
blockExplorerUrls: [explorerUrl],
}],
});
} catch (addError) {
// handle "add" error
}
}
// handle other "switch" errors
}
}
async function getTokenID(ipfsHash) {
await addAvalancheNetwork();
if (ipfsHash != '') {
const tokenId = await contract.methods.metadataTokenId(ipfsHash).call();
return tokenId;
}
}
async function isMinted(ipfsHash) {
await addAvalancheNetwork();
if (ipfsHash != '') {
const tokenId = await getTokenID(ipfsHash)
if (tokenId == 0) {
return false;
} else {
return true;
}
} else {
return false
}
}
</script>
+33
View File
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
{% if request.args.get('ipfs_error') %}
<div class="screen">
<p>
You can run your own local IPFS instance and maintain metadata and artwork,
try installing the software and running the following:
</p>
<code>$ ipfs daemon</code>
</div>
{% endif %}
{% if memes %}
{% for _meme in memes | batch(4) %}
<div class="columns">
{% for meme in _meme %}
{% include 'includes/meme_card.html' %}
{% endfor %}
</div>
{% endfor %}
{% endif %}
</div>
</section>
{% include 'includes/footer.html' %}
</body>
</html>
+322
View File
@@ -0,0 +1,322 @@
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
{% if meme %}
{% if meme.meta_ipfs_hash %}
{% set meta_url = config.IPFS_SERVER + "/ipfs/" + meme.meta_ipfs_hash %}
{% set meme_url = config.IPFS_SERVER + "/ipfs/" + meme.meme_ipfs_hash %}
{% else %}
{% set meta_url = "" %}
{% set meme_url = url_for('meta.uploaded_file', filename=meme.file_name, _external=True) %}
{% endif %}
<div id="screen">
<div class="screen">
{% if meme.file_name %}
{% if meme.file_name.endswith('mp4') %}
<video style="max-height: 60vh!important;max-width:100%;" {% if not request.MOBILE %}autoplay{% else %}controls{% endif %} muted loop>
<source src="{{ meme_url }}" type="video/mp4">
Your browser does not support the video tag.
</video>
{% else %}
<img src="{{ meme_url }}" id="memeImage" />
{% endif %}
{% endif %}
<br/>
<p><strong>Meme ID:</strong> <code>{{ meme }}</code></p>
<p><strong>Title:</strong> {{ meme.title }}</p>
<p><strong>Description:</strong> {{ meme.description }}</p>
<p><strong>Poster Address:</strong> <a href="{{ url_for('user.show', handle=meme.user.handle) }}">{{ meme.user.handle }}</a></p>
{% if meme.user.wownero_address %}
<p><strong>Wownero Address:</strong> <span style="word-wrap: break-word">{{ meme.user.wownero_address }}</p>
{% endif %}
{% if meme.meta_ipfs_hash %}
<p><strong>Meta IPFS:</strong> <a href="{{ meta_url }}" target=_self>{{ meme.meta_ipfs_hash }}</a></p>
{% endif %}
{% if meme.meme_ipfs_hash %}
<p><strong>Meme IPFS:</strong> <a href="{{ meme_url }}" target=_self>{{ meme.meme_ipfs_hash }}</a></p>
{% endif %}
{% if meme.minted %}
<p>
<strong>Tips Received:</strong>
<span id="wowTips">?</span> WOW,
<span id="wowxTips">?</span> WOWX,
<span id="avaxTips">?</span> AVAX
</p>
<p><strong>Token ID:</strong> <span id="tokenId">? </span> <a href="{{ config.CONTRACT_ADDRESS | show_snowtrace }}">(contract)</a></p>
{% endif %}
{% if not meme.approved %}
<br/>
<div class="columns">
<div class="column">
<a class="button is-success is-12 column" href="{{ url_for('meme.approve', meme_id=meme.id, action='approve') }}">Approve</a>
</div>
<div class="column">
<a class="button is-danger is-12 column" href="{{ url_for('meme.approve', meme_id=meme.id, action='deny') }}">Deny</a>
</div>
</div>
{% else %}
<br/>
<div id="tipButtons" class="columns" style="display:none;">
{% if meme.user.wownero_address %}
<div class="column">
<a class="button is-info is-12" onclick="tipWOW()" id="tipWOWButton" style="width:100%;">Tip via p2p WOW</a>
</div>
{% endif %}
<div class="column">
<a class="button is-info is-12" onclick="tipSC()" id="tipSCButton" style="width:100%;">Tip via Smart Contract</a>
</div>
</div>
<div id="mintButtons" class="columns" style="display:none;">
{% if meme.user.wownero_address %}
<div class="column is-one-third">
<a class="button is-info" onclick="tipWOW()" id="tipWOWButton" style="width:100%;">Tip via p2p WOW</a>
</div>
{% endif %}
<div class="column">
<a class="button is-danger" onclick="mint()" id="mintButton" style="width:100%;">Mint to AVAX</a>
</div>
</div>
{% endif %}
<div id="tipWOWModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Tip via WOW p2p</p>
<button class="delete" aria-label="close" id="closeWOWModal"></button>
</header>
<section class="modal-card-body">
<p style="word-wrap: break-word;">
<strong>Wownero Address (p2p):</strong>
<span id="wowneroAddress">{{ meme.user.wownero_address | default('?') }}</span>
</p>
</br>
<p>
Funds sent to this address will not be tracked as they are a direct,
p2p transaction on the anonymous, private Wownero network.
Nobody will have visibility into these and will not be shown.
</p>
</br>
<a href="https://wownero.org/#wallets" target="_blank">Wownero Wallets</a>
</section>
<footer class="modal-card-foot">
</footer>
</div>
</div>
<div id="tipSCModal" class="modal">
<div class="modal-background"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Tip via Smart Contract</p>
<button class="delete" aria-label="close" id="closeSCModal"></button>
</header>
<section class="modal-card-body">
<p>
Sending a tip via the <a href="{{ config.CONTRACT_ADDRESS | show_snowtrace }}" target="_blank">tubbymemes smart contract</a> will send a small amount to the
publisher (the address that minted the meme) and the contract owner ({{ config.CONTRACT_OWNER }}), and the remainder is sent to the meme creator (whoever uploaded it). Everyone gets tipped for their contribution, but primarily the meme creator.
</p>
</br>
<p>
Input the amount of WOWX or AVAX you would like to send and click a button below to sign the transaction in MetaMask wallet.
</p>
</br>
<div class="field">
<label class="label">Tip Amount</label>
<div class="control">
<input id="tipSCAmount" class="input" type="text" placeholder=".042069">
</div>
</div>
</section>
<footer class="modal-card-foot">
<button id="tipWOWXButton" class="button is-success" onclick="tipWOWX()">Tip WOWX</button>
<button id="tipAVAXButton" class="button" onclick="tipAVAX()">Tip AVAX</button>
</footer>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% include 'includes/footer.html' %}
{% if meme.approved %}
<script type="text/javascript">
window.addEventListener('DOMContentLoaded', async () => {
// Show buttons depending on if minted or not
let minted = await isMinted('{{ meme.meta_ipfs_hash }}');
if (minted) {
document.getElementById('tipButtons').style.display = "flex";
} else {
document.getElementById('mintButtons').style.display = "flex";
}
// Show tip amounts
try {
const tokenId = await getTokenID('{{ meme.meta_ipfs_hash }}');
const memeDeets = await contract.methods.tokenMeme(tokenId).call();
document.getElementById('tokenId').innerHTML = tokenId;
document.getElementById('wowxTips').innerHTML = await w3.utils.fromWei(memeDeets.creatorTipsWOWX);
document.getElementById('avaxTips').innerHTML = await w3.utils.fromWei(memeDeets.creatorTipsAVAX);
} catch(e) {
console.log(e);
}
});
async function tipWOW() {
document.getElementById('tipWOWButton').classList.add('is-loading');
document.getElementById('tipWOWModal').classList.add('is-active');
document.getElementById('closeWOWModal').onclick = () => {
document.getElementById('tipWOWModal').classList.remove('is-active');
document.getElementById('tipWOWButton').classList.remove('is-loading');
}
}
async function tipSC() {
document.getElementById('tipSCButton').classList.add('is-loading');
document.getElementById('tipSCModal').classList.add('is-active');
document.getElementById('closeSCModal').onclick = () => {
document.getElementById('tipSCModal').classList.remove('is-active');
document.getElementById('tipSCButton').classList.remove('is-loading');
}
}
async function getTipAmount() {
const amt = document.getElementById('tipSCAmount').value;
if (isNaN(amt)) {
notif('You must provide a number.', 'warning');
return false;
} else {
return amt;
}
}
async function tipAVAX() {
document.getElementById('tipAVAXButton').classList.add('is-loading');
const tokenId = await getTokenID('{{ meme.meta_ipfs_hash }}');
const walletAddress = await getMetamaskAccount();
const tipAmount = await getTipAmount();
if (!tipAmount) {
document.getElementById('tipAVAXButton').classList.remove('is-loading');
return false;
}
const tipAmountWei = w3.utils.toWei(tipAmount);
const gasPrice = await w3.eth.getGasPrice();
try {
const gasLimit = await contract.methods.tipAVAX(tokenId, tipAmountWei).estimateGas({from: walletAddress, value: tipAmountWei}, function(err, gas){
return gas;
});
notif(`Tipping ${tipAmount} AVAX to meme "{{ meme.id | shorten_address }}" by "{{ meme.user.handle }} ({{ meme.user.public_address | shorten_address }})" via the Avalanche blockchain.`, 'info');
let res = await contract.methods.tipAVAX(tokenId, tipAmountWei).send({
from: walletAddress,
value: tipAmountWei,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('tipAVAXButton').classList.remove('is-loading');
}
}
async function approveWOWX(amountWei) {
const walletAddress = await getMetamaskAccount();
const allowanceWei = await wowxContract.methods.allowance(walletAddress, contractAddress).call();
if (amountWei <= allowanceWei) {
return true;
}
const gasPrice = await w3.eth.getGasPrice();
const gasLimit = await wowxContract.methods.approve(contractAddress, amountWei).estimateGas({from: walletAddress}, function(err, gas){
return gas;
});
try {
notif(`Approving usage of WOWX token.`, 'info');
let res = await wowxContract.methods.approve(contractAddress, amountWei).send({
from: walletAddress,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
return true;
} catch(e) {
notif(e.message, 'warning');
}
}
async function tipWOWX() {
document.getElementById('tipWOWXButton').classList.add('is-loading');
const tipAmount = await getTipAmount();
if (!tipAmount) {
document.getElementById('tipWOWXButton').classList.remove('is-loading');
return false;
}
const tipAmountWei = w3.utils.toWei(tipAmount);
const tokenId = await getTokenID('{{ meme.meta_ipfs_hash }}');
const walletAddress = await getMetamaskAccount();
const approved = await approveWOWX(tipAmountWei);
if (!approved) {
document.getElementById('tipWOWXButton').classList.remove('is-loading');
return false;
}
const gasPrice = await w3.eth.getGasPrice();
try {
const gasLimit = await contract.methods.tipWOWX(tokenId, tipAmountWei).estimateGas({from: walletAddress}, function(err, gas){
return gas;
});
notif(`Tipping ${tipAmount} WOWX to meme "{{ meme.id | shorten_address }}" by "{{ meme.user.handle }} ({{ meme.user.public_address | shorten_address }})".`, 'info');
let res = await contract.methods.tipWOWX(tokenId, tipAmountWei).send({
from: walletAddress,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('tipWOWXButton').classList.remove('is-loading');
}
}
async function mint() {
if (await isMinted('{{ meme.meta_ipfs_hash }}')) {
notif('Already minted!', 'warning');
return
}
document.getElementById('mintButton').classList.add('is-loading');
const walletAddress = await getMetamaskAccount();
const gasPrice = await w3.eth.getGasPrice();
const gasLimit = await contract.methods.mint("{{ meme.meta_ipfs_hash }}", "{{ meme.user.public_address }}").estimateGas({from: walletAddress}, function(err, gas){
return gas;
});
try {
notif(`Minting meme "{{ meme.id | shorten_address }}" by "{{ meme.user.handle }} ({{ meme.user.public_address | shorten_address }})" to the Avalanche blockchain.`, 'info');
let res = await contract.methods.mint("{{ meme.meta_ipfs_hash }}", "{{ meme.user.public_address }}").send({
from: walletAddress,
value: 0,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('mintButton').classList.remove('is-loading');
}
}
</script>
{% endif %}
</body>
</html>
+236
View File
@@ -0,0 +1,236 @@
{% set is_user = current_user == user %}
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
{% if user %}
<div class="screen">
<div class="columns">
<div class="column is-full content">
<h3>From Avalanche Blockchain</h3>
<p>Handle: <strong><span id="userHandle" class="mr-4">?</span></strong></p>
<p>Wownero Address: <strong><span id="wowneroAddress" class="mr-4 wrap">?</span></strong></p>
<p>Profile IPFS Hash: <strong><span id="metadataIPFSHash">?</span></strong></p>
<p>Tipped AVAX: <strong><span id="tippedAVAX">?</span></strong></p>
<p>Tipped WOWX: <strong><span id="tippedWOWX">?</span></strong></p>
<p>Tipped WOW: <strong><span id="tippedWOW">?</span></strong></p>
<h3>From Local Database</h3>
{# Handle #}
{% if is_user %}
<div class="field">
<label class="label">Handle</label>
<div class="control columns">
<div class="column is-two-thirds">
<input id="handleInput" class="input" type="text" placeholder="{{ user.handle }}" value="{{ user.handle }}">
<p class="help">The username/handle you want to be referenced by.</p>
</div>
<div class="column">
<a id="publishHandle" onclick="publishHandle()" class="publishAVAX button">Publish AVAX</a>
</div>
</div>
</div>
{% else %}
<p><strong>Handle:</strong> {{ user.handle }}</p>
{% endif %}
{# Wownero Address #}
{% if is_user %}
<div class="field">
<label class="label">Wownero Address</label>
<div class="control columns">
<div class="column is-two-thirds">
<input id="wowneroAddressInput" class="input" type="text" placeholder="{{ user.wownero_address or '' }}" value="{{ user.wownero_address or '' }}">
<p class="help">The Wownero wallet address you want to receive tips on.</p>
</div>
<div class="column">
<a id="publishWowneroAddress" onclick="publishWowneroAddress()" class="publishAVAX button">Publish AVAX</a>
</div>
</div>
</div>
{% else %}
<p><strong>Wownero Address:</strong> <span class="wrap">{{ user.wownero_address or '?' }}</span></p>
{% endif %}
{# Metadata IPFS Hash #}
{% if is_user %}
<div class="field">
<label class="label">Metadata IPFS Hash</label>
<div class="control columns">
<div class="column is-two-thirds">
<input id="metadataIPFSHashInput" class="input" type="text" placeholder="{{ user.ipfs_hash or '' }}" value="{{ user.ipfs_hash or '' }}">
<p class="help">The IPFS hash of your profile metadata.</p>
</div>
<div class="column">
<a id="publishMetadataIPFSHash" onclick="publishMetadataIPFSHash()" class="publishAVAX button">Publish AVAX</a>
</div>
</div>
</div>
{% else %}
<p><strong>Metadata IPFS Hash:</strong> {{ user.ipfs_hash or '' }}</p>
{% endif %}
<p><strong>Register Date: </strong> {{ user.register_date }} ({{ user.register_date | humanize }})</p>
<p><strong>Last Login Date: </strong> {{ user.last_login_date }} ({{ user.last_login_date | humanize }})</p>
<p><strong>Moderator: </strong> {{ user.is_moderator() }}</p>
<p><strong>Verified: </strong> {{ user.verified }}</p>
<p><strong>Memes Posted: </strong> {{ user.memes | length }}</p>
{% if user.bio %}
<p><strong>Bio: </strong> {{ user.bio }}</p>
{% endif %}
{% if user.website %}
<p>Website: <a href="{{ user.website_url }}" target="_blank">{{ user.website_url }}</a></p>
{% endif %}
{% if is_user %}
<p><a onclick="saveDB()" class="ml-2 button is-primary">Save DB</a></p>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</section>
{% include 'includes/footer.html' %}
<script type="text/javascript">
async function saveDB() {
await fetch('{{ url_for("api.update_user" ) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json;charset=utf-8'
},
body: JSON.stringify({
'user_id': {{ user.id }},
'handle': document.getElementById('handleInput').value,
'wownero_address': document.getElementById('wowneroAddressInput').value,
'ipfs_hash': document.getElementById('metadataIPFSHashInput').value,
})
})
.then((resp) => resp.json())
.then(function(data) {
console.log(data)
if (data['success']) {
notif('Updated user information in this server\'s database.', 'success');
notif('Publish to the Avalanche blockchain to make the data globally available.', 'info');
} else {
notif(data['message'], 'error');
}
})
}
async function fetchUserProfile() {
await addAvalancheNetwork();
_walletAddress = w3.utils.toChecksumAddress('{{ user.public_address }}');
const userProfile = await contract.methods.userProfile(_walletAddress).call();
if (userProfile.userHandle){document.getElementById('userHandle').innerHTML = userProfile.userHandle};
if (userProfile.wowneroAddress){document.getElementById('wowneroAddress').innerHTML = userProfile.wowneroAddress};
if (userProfile.metadataIPFSHash){document.getElementById('metadataIPFSHash').innerHTML = userProfile.metadataIPFSHash};
if (userProfile.tippedAVAX){document.getElementById('tippedAVAX').innerHTML = w3.utils.fromWei(userProfile.tippedAVAX)};
if (userProfile.tippedWOWX){document.getElementById('tippedWOWX').innerHTML = w3.utils.fromWei(userProfile.tippedWOWX)};
return
}
async function publishHandle() {
document.getElementById('publishHandle').classList.add('is-loading');
const handle = document.getElementById('handleInput').value;
if (handle == "") {
notif('Cannot publish an empty value.', 'warning');
document.getElementById('publishHandle').classList.remove('is-loading');
return false;
}
await addAvalancheNetwork();
try {
const walletAddress = await getMetamaskAccount();
const gasPrice = await w3.eth.getGasPrice();
const gasLimit = await contract.methods.setUserHandle(handle).estimateGas(function(err, gas){
return gas;
});
notif(`Publishing user handle "${handle}" to the Avalanche blockchain for address ${walletAddress.slice(0, 6)}...${walletAddress.slice(-6)}.`, 'info');
let res = await contract.methods.setUserHandle(handle).send({
from: walletAddress,
value: 0,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('publishHandle').classList.remove('is-loading');
}
}
async function publishWowneroAddress() {
document.getElementById('publishWowneroAddress').classList.add('is-loading');
const address = document.getElementById('wowneroAddressInput').value;
if (address == "") {
notif('Cannot publish an empty value.', 'warning');
document.getElementById('publishWowneroAddress').classList.remove('is-loading');
return false;
}
await addAvalancheNetwork();
try {
const walletAddress = await getMetamaskAccount();
const gasPrice = await w3.eth.getGasPrice();
const gasLimit = await contract.methods.setUserWowneroAddress(address).estimateGas(function(err, gas){
return gas;
});
notif(`Publishing Wownero address "${address.slice(0, 6)}...${address.slice(-6)}" to the Avalanche blockchain for address ${walletAddress.slice(0, 6)}...${walletAddress.slice(-6)}.`, 'info');
let res = await contract.methods.setUserWowneroAddress(address).send({
from: walletAddress,
value: 0,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('publishWowneroAddress').classList.remove('is-loading');
}
}
async function publishMetadataIPFSHash() {
document.getElementById('publishMetadataIPFSHash').classList.add('is-loading');
const _hash = document.getElementById('metadataIPFSHashInput').value;
if (_hash == "") {
notif('Cannot publish an empty value.', 'warning');
document.getElementById('publishMetadataIPFSHash').classList.remove('is-loading');
return false;
}
await addAvalancheNetwork();
try {
const walletAddress = await getMetamaskAccount();
const gasPrice = await w3.eth.getGasPrice();
const gasLimit = await contract.methods.setUserMetadata(_hash).estimateGas(function(err, gas){
return gas;
});
notif(`Publishing user profile metadata IPFS hash "${_hash}" to the Avalanche blockchain for address ${walletAddress.slice(0, 6)}...${walletAddress.slice(-6)}.`, 'info');
let res = await contract.methods.setUserMetadata(_hash).send({
from: walletAddress,
value: 0,
gasPrice: gasPrice,
gas: gasLimit
});
console.log(res);
window.location.href = "";
} catch(e) {
notif(e.message, 'warning');
document.getElementById('publishMetadataIPFSHash').classList.remove('is-loading');
}
}
window.addEventListener('DOMContentLoaded', () => {
fetchUserProfile();
});
</script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
<div class="screen">
<form method="POST" enctype="multipart/form-data" class="site-form" id="memeUpload" action="{{ url_for('meme.publish') }}" style="padding-top:1.5em;">
<div class="field">
<label class="label">File</label>
<div class="control">
<input class="filestyle" id="file" name="file" required type="file">
</div>
</div>
<div class="field">
<label class="label">Poster Address</label>
<div class="control">
<p>{{ current_user.public_address }}</p>
</div>
</div>
<div class="field">
<label class="label">Title</label>
<div class="control">
<input class="input" type="text" placeholder="Title of your meme" name="title">
</div>
</div>
<div class="field">
<label class="label">Description</label>
<div class="control">
<textarea class="textarea" placeholder="Description..." name="description"></textarea>
</div>
</div>
<div class="field">
<div class="control">
<label class="checkbox">
<input type="checkbox">
I agree to support the ongoing funding of exploratory meme missions
</label>
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary" up-follow=".container">Submit</button>
</div>
</div>
</form>
</div>
</div>
</section>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html>
{% include 'includes/head.html' %}
<body>
<section class="section">
<div class="container">
{% include 'includes/navbar.html' %}
<div class="screen">
<form method="POST" enctype="multipart/form-data" class="site-form" action="{{ url_for('meta.remotes') }}" style="padding-top:1.5em;">
<div class="field">
<label class="label">Remote Server</label>
<div class="control">
<input class="input" type="text" placeholder="https://tubbymemes.xyz" name="endpoint">
</div>
</div>
<div class="field is-grouped">
<div class="control">
<button class="button is-primary">Submit</button>
</div>
</div>
</form>
{% if remotes %}
<hr>
{% for remote in remotes %}
<p>{{ remote.endpoint }} - last synced: {{ remote.last_sync_date | humanize }} <a href="{{ url_for('meta.delete_remote', remote_id=remote.id) }}">x</a></p>
{% endfor %}
{% endif %}
</div>
</div>
</section>
{% include 'includes/footer.html' %}
</body>
</html>