version 0.1.0

This commit is contained in:
mystieneko 2024-08-27 22:43:46 +03:00
parent 89162969b8
commit 046bcf740e
23 changed files with 806 additions and 0 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
venv/
__pycache__/
.env

327
app.py Normal file
View file

@ -0,0 +1,327 @@
from flask import Flask, Blueprint, jsonify, request, abort, render_template, flash, session, redirect, url_for
from dotenv import load_dotenv
from mysql.connector import errorcode
from functools import wraps
import mysql.connector
import functions as func
import os
import json
import config as cfg
import constants as const
# used for admin routes
logged_in = False
load_dotenv()
app = Flask(cfg.appName)
app.config["SECRET_KEY"] = os.environ.get("APP_SECRET")
# -- blueprints --
api_bp = Blueprint('api', cfg.appName)
admin_bp = Blueprint('admin', cfg.appName)
# -- cli commands --
@app.cli.command("init-db")
def initDatabase():
dbName = os.environ.get("DB_NAME")
print(f"Attempting to connect to database {dbName}...")
try:
conn = func.connectToDb()
cursor = conn.cursor()
print("Connected successfully")
conn.database = dbName
except mysql.connector.Error as error:
if error.errno == errorcode.ER_ACCESS_DENIED_ERROR:
print("Bad credentials")
elif error.errno == errorcode.ER_BAD_DB_ERROR:
conn = mysql.connector.connect(
user=os.environ.get("DB_USER"),
password=os.environ.get("DB_PASS"),
host=os.environ.get("DB_HOST"),
database='mysql'
)
cursor = conn.cursor()
func.createDatabase(cursor, dbName)
conn.database = dbName
else:
print("Error:", error)
return
with open('schema.sql', 'r') as schema_file:
schema = schema_file.read()
try:
cursor.execute(schema, multi=True)
print(f"Database {dbName} was successfully initialized!")
except mysql.connector.Error as error:
print(f"Failed to initialize database {dbName}: {error}")
finally:
cursor.close()
conn.close()
# -- decorators --
def loginRequired(f):
@wraps(f)
def login_required(*args, **kwargs):
if not logged_in:
return abort(403)
return f(*args, **kwargs)
return login_required
# -- before request --
@app.before_request
def before_request():
global logged_in
logged_in = session.get('logged_in')
# -- context processors --
@app.context_processor
def inject_stuff():
return dict(cfg=cfg, logged_in=logged_in, version=const.version, appName=cfg.appName)
# -- client (frontend) routes --
@app.route('/', methods=['GET'])
def index():
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (True,))
questions = cursor.fetchall()
cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC")
answers = cursor.fetchall()
combined = []
for question in questions:
question_answers = [answer for answer in answers if answer['question_id'] == question['id']]
combined.append({
'question': question,
'answers': question_answers
})
cursor.close()
conn.close()
return render_template('index.html', combined=combined, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime, str=str)
@app.route('/inbox/', methods=['GET'])
@loginRequired
def inbox():
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE answered=%s", (False,))
questions = cursor.fetchall()
cursor.close()
conn.close()
return render_template('inbox.html', questions=questions, formatRelativeTime=func.formatRelativeTime, str=str, len=len)
@app.route('/q/<int:question_id>/', methods=['GET'])
def viewQuestion(question_id):
question = func.getQuestion(question_id)
answer = func.getAnswer(question_id)
return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str)
# -- admin client routes --
@admin_bp.route('/login/', methods=['GET', 'POST'])
def login():
global logged_in
if request.method == 'POST':
admin_password = request.form.get('admin_password')
if admin_password == os.environ.get('ADMIN_PASSWORD'):
session['logged_in'] = True
return redirect(url_for('admin.index'))
else:
flash("Wrong password", 'danger')
return redirect(url_for('admin.login'))
else:
if logged_in:
return redirect('admin.index')
else:
return render_template('admin/login.html')
@admin_bp.route('/logout/')
@loginRequired
def logout():
session['logged_in'] = False
return redirect(url_for('index'))
@admin_bp.route('/', methods=['GET', 'POST'])
@loginRequired
def index():
if request.method == 'POST':
action = request.form.get('action')
blacklist = request.form.get('blacklist')
if action == 'update_word_blacklist':
with open(const.blacklistFile, 'w') as file:
file.write(blacklist)
flash("Changes saved!", 'success')
return redirect(url_for('admin.index'))
else:
blacklist = func.readPlainFile(const.blacklistFile)
return render_template('admin/index.html', blacklist=blacklist)
# -- server routes --
@api_bp.errorhandler(404)
def notFound():
return jsonify({'error': 'Not found'}), 404
@api_bp.errorhandler(400)
def badRequest(e):
return jsonify({'error': str(e)}), 400
@api_bp.errorhandler(500)
def badRequest(e):
return jsonify({'error': str(e)}), 500
@api_bp.route('/add_question/', methods=['POST'])
def addQuestion():
from_who = request.form.get('from_who', 'Anonymous')
question = request.form.get('question', '')
antispam = request.form.get('antispam', '')
if not question:
abort(400, "Question field must not be empty")
if not antispam:
abort(400, "Anti-spam word must not be empty")
antispam_wordlist = func.readPlainFile(const.antiSpamFile, split=True)
antispam_valid = antispam in antispam_wordlist
if not antispam_valid:
abort(400, "Anti-spam is not valid")
blacklist = func.readPlainFile(const.blacklistFile, split=True)
if question in blacklist:
abort(500, "An error has occurred")
conn = func.connectToDb()
cursor = conn.cursor()
cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (from_who, question, False,))
cursor.close()
conn.close()
return {'message': 'Question asked successfully!'}, 201
@api_bp.route('/delete_question/', methods=['DELETE'])
def deleteQuestion():
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor()
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
cursor.close()
conn.close()
return {'message': 'Successfully deleted question.'}, 200
@api_bp.route('/return_to_inbox/', methods=['POST'])
def returnToInbox():
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor()
cursor.execute("SELECT from_who, content FROM questions WHERE id=%s", (question_id,))
row = cursor.fetchone()
question = {
'from_who': row[0],
'content': row[1]
}
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (question["from_who"], question["content"], False,))
cursor.close()
conn.close()
return {'message': 'Successfully returned question to inbox.'}, 200
@api_bp.route('/add_answer/', methods=['POST'])
def addAnswer():
question_id = request.args.get('question_id', '')
answer = request.form.get('answer')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
if not answer:
abort(400, "Missing 'answer' attribute or 'answer' is empty")
conn = func.connectToDb()
try:
cursor = conn.cursor()
cursor.execute("INSERT INTO answers (question_id, content) VALUES (%s, %s)", (question_id, answer))
answer_id = cursor.lastrowid
cursor.execute("UPDATE questions SET answered=%s, answer_id=%s WHERE id=%s", (True, answer_id, question_id))
conn.commit()
except Exception as e:
conn.rollback()
return jsonify({'error': str(e)}), 500
finally:
cursor.close()
conn.close()
return jsonify({'message': 'Answer added successfully!'}), 201
@api_bp.route('/all_questions/', methods=['GET'])
def listQuestions():
answered = request.args.get('answered', False)
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id, from_who, creation_date, content, answered, answer_id FROM questions WHERE answered=%s", (answered,))
questions = cursor.fetchall()
cursor.close()
conn.close()
return jsonify(questions)
@api_bp.route('/all_answers/', methods=['GET'])
def listAnswers():
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id, question_id, creation_date, content FROM answers")
answers = cursor.fetchall()
cursor.close()
conn.close()
return jsonify(answers)
@api_bp.route('/view_question/', methods=['GET'])
def viewQuestion():
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id, from_who, creation_date, content, answered, answer_id FROM questions WHERE id=%s", (question_id,))
question = cursor.fetchone()
cursor.close()
conn.close()
return jsonify(question)
@api_bp.route('/view_answer/', methods=['GET'])
def viewAnswer():
answer_id = request.args.get('answer_id', '')
if not answer_id:
abort(400, "Missing 'answer_id' attribute or 'answer_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT id, question_id, creation_date, content FROM answers WHERE id=%s", (answer_id,))
answer = cursor.fetchone()
cursor.close()
conn.close()
return jsonify(answer)
app.register_blueprint(api_bp, url_prefix='/api/v1')
app.register_blueprint(admin_bp, url_prefix='/admin')
if __name__ == '__main__':
app.run(debug=True)

2
config.py Normal file
View file

@ -0,0 +1,2 @@
appName = 'CatAsk'
fullBaseUrl = 'http://192.168.92.146:5000'

3
constants.py Normal file
View file

@ -0,0 +1,3 @@
antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt'
version = '0.1.0'

73
functions.py Normal file
View file

@ -0,0 +1,73 @@
from datetime import datetime
import humanize
import mysql.connector
import config as cfg
import os
import random
import constants as const
def formatRelativeTime(date_str):
date_format = "%Y-%m-%d %H:%M:%S"
past_date = datetime.strptime(date_str, date_format)
now = datetime.now()
time_difference = now - past_date
return humanize.naturaltime(time_difference)
dbHost = os.environ.get("DB_HOST")
dbUser = os.environ.get("DB_USER")
dbPass = os.environ.get("DB_PASS")
dbName = os.environ.get("DB_NAME")
def createDatabase(cursor, dbName):
try:
cursor.execute("CREATE DATABASE {} DEFAULT CHARACTER SET 'utf8'".format(dbName))
print(f"Database {dbName} created successfully")
except mysql.connector.Error as error:
print("Failed to create database:", error)
exit(1)
def connectToDb():
conn = mysql.connector.connect(
host=dbHost,
user=dbUser,
password=dbPass,
database=dbName,
pool_name=f"{cfg.appName}-pool",
pool_size=32,
autocommit=True
)
return conn
def getQuestion(question_id: int):
conn = connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE id=%s", (question_id,))
question = cursor.fetchone()
cursor.close()
conn.close()
return question
def getAnswer(question_id: int):
conn = connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM answers WHERE question_id=%s", (question_id,))
answer = cursor.fetchone()
cursor.close()
conn.close()
return answer
def readPlainFile(file, split=False):
if os.path.exists(file):
with open(file, 'r', encoding="utf-8") as file:
if split == False:
return file.read()
if split == True:
return file.read().splitlines()
else:
return []
def getRandomWord():
items = readPlainFile(const.antiSpamFile, split=True)
return random.choice(items)

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
flask
python-dotenv
mysql-connector-python==8.2.0

7
roadmap.md Normal file
View file

@ -0,0 +1,7 @@
# CatAsk 1.0 roadmap
[x] deleting answered questions OR returning them to inbox like retrospring does
[ ] bulk deleting questions from inbox
[ ] blocking askers by ip
[x] make an admin page
[x] implement an optional blacklist of words

21
schema.sql Normal file
View file

@ -0,0 +1,21 @@
CREATE TABLE IF NOT EXISTS answers (
id INT PRIMARY KEY AUTO_INCREMENT,
question_id INT NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL
) ENGINE=InnoDB;
CREATE TABLE IF NOT EXISTS questions (
id INT PRIMARY KEY AUTO_INCREMENT,
from_who VARCHAR(255) NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL,
answered BOOLEAN NOT NULL DEFAULT FALSE,
answer_id INT
) ENGINE=InnoDB;
ALTER TABLE questions
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;
ALTER TABLE answers
ADD CONSTRAINT fk_question_id FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE;

5
static/css/bootstrap-icons.min.css vendored Normal file

File diff suppressed because one or more lines are too long

6
static/css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

48
static/css/style.css Normal file
View file

@ -0,0 +1,48 @@
@font-face {
font-family: "Rubik";
font-display: swap;
font-weight: 100 900;
src: url("../fonts/rubik.woff2") format('woff2-variations');
}
:root, [data-bs-theme=light] {
--bs-primary: #6345d9;
--bs-primary-rgb: 214,107,26;
--bs-link-color-rgb: var(--bs-primary-rgb);
--bs-nav-link-color: var(--bs-primary);
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
--bs-font-sans-serif: "Rubik", sans-serif;
--bs-dropdown-link-hover-bg: var(--bs-primary);
}
.btn {
--bs-btn-border-radius: .5rem;
}
.btn-primary {
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white);
--bs-btn-border-color: var(--bs-btn-bg);
--bs-btn-hover-bg: color-mix(in srgb, var(--bs-btn-bg) 90%, black);
--bs-btn-hover-border-color: var(--bs-btn-hover-bg);
--bs-btn-active-bg: color-mix(in srgb, var(--bs-btn-bg) 80%, black);
--bs-btn-active-border-color: var(--bs-btn-active-bg);
font-weight: 500;
}
.nav-link {
color: color-mix(in srgb, var(--bs-primary) 90%, black);
}
.dropdown-item.active, .dropdown-item:active {
background-color: var(--bs-primary);
}
.form-control:focus {
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent);
border-color: color-mix(in srgb, var(--bs-primary), transparent);
}
.icon-link.dropdown-toggle::after {
border: none;
display: none;
}

Binary file not shown.

BIN
static/fonts/rubik.woff2 Normal file

Binary file not shown.

7
static/js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
static/js/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,18 @@
{% extends 'base.html' %}
{% block title %}Admin{% endblock %}
{% set adminLink = 'active' %}
{% block content %}
<h1>Admin panel</h1>
<div class="card">
<div class="card-body">
<form action="{{ url_for('admin.index') }}" method="POST">
<input type="hidden" name="action" value="update_word_blacklist">
<div class="form-floating mb-3">
<textarea id="blacklist" name="blacklist" style="height: 400px;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea>
<label for="blacklist">Word blacklist</label>
</div>
<button type="submit" class="btn btn-primary">Save</button>
</form>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% block content %}
<div class="card">
<div class="card-body">
<form action="{{ url_for('admin.login') }}" method="POST">
<div class="form-floating mb-3">
<input type="password" class="form-control" id="admin_password" name="admin_password" placeholder="Password">
<label for="admin_password">Password</label>
</div>
<button type="submit" class="btn btn-primary">Login</button>
</form>
</div>
</div>
{% endblock %}

52
templates/base.html Normal file
View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="htmx-config" content='{ "responseHandling": [{ "code": "[45]", "swap": true }] }'>
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.min.css') }}">
<link rel="preload" href="{{ url_for('static', filename='fonts/bootstrap-icons.woff2') }}" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
<title>{% block title %}{% endblock %} - {{ cfg.appName }}</title>
</head>
<body class="m-2">
<div class="container-fluid">
{% if logged_in %}
<div class="d-flex justify-content-between">
{% endif %}
<ul class="nav nav-underline mt-2 mb-2">
<li class="nav-item"><a class="nav-link {{ homeLink }}" id="home-link" href="{{ url_for('index') }}">Home</a>
{%- if logged_in -%}
<li class="nav-item"><a class="nav-link {{ inboxLink }}" id="inbox-link" href="{{ url_for('inbox') }}">Inbox</a>
<li class="nav-item"><a class="nav-link {{ adminLink }}" id="admin-link" href="{{ url_for('admin.index') }}">Admin</a></li>
{%- endif -%}
</ul>
{% if logged_in %}
<ul class="nav nav-underline mt-2 mb-2">
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul>
</div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<div>{{ message }}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
{% block scripts %}
{% endblock %}
</div>
<footer class="py-3 my-4">
<p class="text-center text-body-secondary">CatAsk v{{ version }}</p>
</footer>
</body>
</html>

52
templates/inbox.html Normal file
View file

@ -0,0 +1,52 @@
{% extends 'base.html' %}
{% block title %}Inbox ({{ len(questions) }}){% endblock %}
{% set inboxLink = 'active' %}
{% block content %}
{% if questions != [] %}
{% for question in questions %}
<div class="card mb-2 mt-2 alert-placeholder" id="question-{{ question.id }}">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mt-0">{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div>
<p>{{ question.content }}</span>
<form hx-post="{{ url_for('api.addAnswer', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">
<div class="form-group d-grid gap-2">
<textarea class="form-control" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
<button type="submit" class="btn btn-primary">Answer</button>
<button type="button" class="btn btn-outline-danger" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Delete</button>
</div>
</form>
</div>
</div>
{% endfor %}
{% else %}
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
const appendAlert = (elementId, message, type) => {
const alertPlaceholder = document.getElementById(elementId);
const alertHtml = `
<div class="alert alert-${type} alert-dismissible" role="alert">
<div>${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
`;
alertPlaceholder.outerHTML = alertHtml;
}
document.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response;
if (jsonResponse) {
const parsed = JSON.parse(jsonResponse);
const msgType = event.detail.successful ? 'success' : 'error';
const targetElementId = event.detail.target.id;
appendAlert(targetElementId, parsed.message, msgType);
}
})
</script>
{% endblock %}

87
templates/index.html Normal file
View file

@ -0,0 +1,87 @@
{% extends 'base.html' %}
{% block title %}Home{% endblock %}
{% set homeLink = 'active' %}
{% block content %}
<div class="mt-3 mb-5">
<h2>Ask a question</h2>
<form hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none">
<div class="form-group d-grid gap-2">
<input class="form-control" type="text" name="from_who" id="from_who" placeholder="Name (optional)">
<textarea class="form-control" required name="question" id="question" placeholder="Write your question..."></textarea>
<label for="antispam">Anti-spam: please enter the word <code>{{ getRandomWord().upper() }}</code> in lowercase</label>
<input class="form-control" type="text" required name="antispam" id="antispam">
<button type="submit" class="btn btn-primary">Ask</button>
</div>
</form>
<div id="response-container" class="mt-3"></div>
</div>
{% for item in combined %}
<div class="card mt-2 mb-2" id="question-{{ item.question.id }}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mt-1 mb-1">{% if item.question.from_who %}{{ item.question.from_who }}{% else %}Anonymous{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ item.question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(item.question.creation_date)) }}</h6>
</div>
<p class="card-text">{{ item.question.content }}</p>
</div>
<div class="card-body">
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="text-decoration-none text-reset">
{% for answer in item.answers %}
<p class="mb-0">{{ answer.content }}</p>
</div>
</a>
<div class="card-footer text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
{% endfor %}
<div class="dropdown">
<a class="text-reset btn-sm icon-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></a>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=item.question.id) }}" hx-target="#question-{{ item.question.id }}" hx-swap="none">Return to inbox</button></li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endfor %}
{% endblock %}
{% block scripts %}
<script>
console.log(navigator.clipboard);
function copy(questionId) {
navigator.clipboard.writeText("{{ cfg.fullBaseUrl }}/q/" + questionId + "/")
}
</script>
<script>
document.getElementById('question-form').reset();
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const appendAlert = (elementId, message, type, onclick) => {
const alertPlaceholder = document.querySelector(`#${elementId}`);
const alertHtml = `
<div class="alert alert-${type} alert-dismissible" role="alert">
<div>${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" onclick=${onclick}></button>
</div>
`;
alertPlaceholder.outerHTML = alertHtml;
}
document.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response;
if (jsonResponse) {
const parsed = JSON.parse(jsonResponse);
const alertType = event.detail.successful ? 'success' : 'danger';
msgType = event.detail.successful ? parsed.message : parsed.error;
const targetElementId = event.detail.target.id;
onclick = event.detail.successful ? null : "window.location.reload()";
appendAlert(targetElementId, msgType, alertType, onclick);
document.getElementById('question-form').reset();
}
})
</script>
{% endblock %}

View file

@ -0,0 +1,26 @@
{% extends 'base.html' %}
{% block content %}
<div class="card mt-2 mb-2">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mt-0">{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div>
<p class="card-text">{{ question.content }}</p>
<p class="mb-0">{{ answer.content }}</p>
</div>
<div class="card-footer text-body-secondary d-flex justify-content-between align--center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div class="dropdown">
<a class="text-reset btn-sm icon-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></a>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Return to inbox</button></li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endblock %}

4
word_blacklist.txt Normal file
View file

@ -0,0 +1,4 @@
faggot
fag
troon
tranny

46
wordlist.txt Normal file
View file

@ -0,0 +1,46 @@
meow
entity
leaves
paper
entry
fuel
pencil
extract
combust
magpie
finch
pampus
mirror
sparkle
flint
photon
electron
neutron
tauon
quasar
candle
script
river
forest
cyan
magenta
yellow
black
marble
object
piano
violin
train
gridlock
winter
spring
summer
autumn
camera
serial
purple
fediverse
indieweb
shrimp
mention
violet