1.0.0-alpha

This commit is contained in:
mst 2024-08-30 23:18:20 +03:00
parent bfa0fa79b2
commit 841edb2f22
15 changed files with 465 additions and 97 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
venv/ venv/
__pycache__/ __pycache__/
.env .env
config.py
word_blacklist.txt

25
app.py
View file

@ -14,12 +14,12 @@ logged_in = False
load_dotenv() load_dotenv()
app = Flask(cfg.appName) app = Flask(const.appName)
app.secret_key = os.environ.get("APP_SECRET") app.secret_key = os.environ.get("APP_SECRET")
# -- blueprints -- # -- blueprints --
api_bp = Blueprint('api', cfg.appName) api_bp = Blueprint('api', const.appName)
admin_bp = Blueprint('admin', cfg.appName) admin_bp = Blueprint('admin', const.appName)
# -- cli commands -- # -- cli commands --
@ -83,7 +83,12 @@ def before_request():
@app.context_processor @app.context_processor
def inject_stuff(): def inject_stuff():
return dict(cfg=cfg, logged_in=logged_in, version=const.version, appName=cfg.appName) return dict(const=const, cfg=cfg, logged_in=logged_in, version_id=const.version_id, version=const.version, appName=const.appName)
# -- template filters --
@app.template_filter('render_markdown')
def render_markdown(text):
return func.renderMarkdown(text)
# -- client (frontend) routes -- # -- client (frontend) routes --
@ -124,7 +129,7 @@ def inbox():
def viewQuestion(question_id): def viewQuestion(question_id):
question = func.getQuestion(question_id) question = func.getQuestion(question_id)
answer = func.getAnswer(question_id) answer = func.getAnswer(question_id)
return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str) return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str, trimContent=func.trimContent)
# -- admin client routes -- # -- admin client routes --
@ -160,8 +165,7 @@ def index():
if action == 'update_word_blacklist': if action == 'update_word_blacklist':
with open(const.blacklistFile, 'w') as file: with open(const.blacklistFile, 'w') as file:
file.write(blacklist) file.write(blacklist)
flash("Changes saved!", 'success') return {'message': 'Changes saved!'}, 200
return redirect(url_for('admin.index'))
else: else:
blacklist = func.readPlainFile(const.blacklistFile) blacklist = func.readPlainFile(const.blacklistFile)
return render_template('admin/index.html', blacklist=blacklist) return render_template('admin/index.html', blacklist=blacklist)
@ -230,16 +234,17 @@ def returnToInbox():
conn = func.connectToDb() conn = func.connectToDb()
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute("SELECT from_who, content FROM questions WHERE id=%s", (question_id,)) cursor.execute("SELECT from_who, content, creation_date FROM questions WHERE id=%s", (question_id,))
row = cursor.fetchone() row = cursor.fetchone()
question = { question = {
'from_who': row[0], 'from_who': row[0],
'content': row[1] 'content': row[1],
'creation_date': row[2]
} }
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,)) 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.execute("INSERT INTO questions (from_who, content, creation_date, answered) VALUES (%s, %s, %s, %s)", (question["from_who"], question["content"], question["creation_date"], False,))
cursor.close() cursor.close()
conn.close() conn.close()

4
config.example.py Normal file
View file

@ -0,0 +1,4 @@
instanceTitle = "CatAsk"
instanceDesc = 'Ask me something!'
instanceImage = '/static/img/ca_screenshot.png'
fullBaseUrl = 'https://<yourdomain>'

View file

@ -1,2 +1,4 @@
appName = 'CatAsk' instanceTitle = "Nekomimi's question box"
instanceDesc = 'Ask me things!'
instanceImage = '/static/img/ca_screenshot.png'
fullBaseUrl = 'http://192.168.92.146:5000' fullBaseUrl = 'http://192.168.92.146:5000'

View file

@ -1,3 +1,6 @@
antiSpamFile = 'wordlist.txt' antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt' blacklistFile = 'word_blacklist.txt'
version = '0.1.1' appName = 'CatAsk'
version = '1.0.0'
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
version_id = '-alpha'

View file

@ -1,4 +1,7 @@
from markupsafe import Markup
from bleach.sanitizer import Cleaner
from datetime import datetime from datetime import datetime
import mistune
import humanize import humanize
import mysql.connector import mysql.connector
import config as cfg import config as cfg
@ -34,7 +37,7 @@ def connectToDb():
user=dbUser, user=dbUser,
password=dbPass, password=dbPass,
database=dbName, database=dbName,
pool_name=f"{cfg.appName}-pool", pool_name=f"{const.appName}-pool",
pool_size=32, pool_size=32,
autocommit=True autocommit=True
) )
@ -71,3 +74,46 @@ def readPlainFile(file, split=False):
def getRandomWord(): def getRandomWord():
items = readPlainFile(const.antiSpamFile, split=True) items = readPlainFile(const.antiSpamFile, split=True)
return random.choice(items) return random.choice(items)
def trimContent(var, trim):
return var[:trim] + '...' if len(var) > trim else var
def renderMarkdown(text):
plugins = [
'strikethrough'
]
allowed_tags = [
'p',
'em',
'b',
'strong',
'i',
'br',
's',
'del'
]
allowed_attrs = {}
# hard_wrap=True means that newlines will be
# converted into <br> tags
#
# yes, markdown usually lets you make line breaks only
# with 2 spaces or <br> tag, but hard_wrap is enabled to keep
# sanity of whoever will use this software
# (after all, not everyone knows markdown syntax)
md = mistune.create_markdown(
escape=True,
plugins=plugins,
hard_wrap=True
)
html = md(text)
cleaner = Cleaner(tags=allowed_tags, attributes=allowed_attrs)
clean_html = cleaner.clean(html)
return Markup(clean_html)
def generateMetadata(question=None):
metadata = {
'title': cfg.instanceTitle,
'description': cfg.instanceDesc,
'url': cfg.fullBaseUrl,
'image': cfg.instanceImage
}

View file

@ -1,4 +1,6 @@
flask flask
python-dotenv python-dotenv
mysql-connector-python==8.2.0 mysql-connector-python==8.2.0
humanize humanize
mistune
bleach

View file

@ -5,44 +5,123 @@
src: url("../fonts/rubik.woff2") format('woff2-variations'); src: url("../fonts/rubik.woff2") format('woff2-variations');
} }
:root, [data-bs-theme=light] { :root {
--bs-primary: #6345d9; --bs-font-sans-serif: "Rubik", sans-serif;
--bs-primary-rgb: 214,107,26;
--bs-link-color-rgb: var(--bs-primary-rgb); --bs-link-color-rgb: var(--bs-primary-rgb);
--bs-nav-link-color: var(--bs-primary); --bs-nav-link-color: var(--bs-primary);
}
[data-bs-theme=light] {
--bs-primary: #6345d9;
--bs-primary-rgb: 99, 69, 217;
--bs-primary-subtle: color-mix(in srgb, var(--bs-primary) 10%, transparent);
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white); --bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
--bs-font-sans-serif: "Rubik", sans-serif; --bs-danger: #dc3545;
--bs-dropdown-link-hover-bg: var(--bs-primary); --bs-danger-bg-subtle: #f8e6e8;
}
[data-bs-theme=dark] {
--bs-primary: #7f62f0;
--bs-primary-rgb: 127,98,240;
--bs-primary-subtle: color-mix(in srgb, var(--bs-primary) 10%, transparent);
--bs-danger-bg-subtle: #2c0b0e;
--bs-link-color: color-mix(in srgb, var(--bs-primary) 70%, white);
} }
.btn { .btn {
--bs-btn-border-radius: .5rem; --bs-btn-border-radius: .5rem;
} }
.btn-primary { [data-bs-theme=light] .btn-primary {
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white); --bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white);
--bs-btn-border-color: var(--bs-btn-bg); --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-bg: color-mix(in srgb, var(--bs-btn-bg) 90%, black);
--bs-btn-hover-border-color: var(--bs-btn-hover-bg); --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-bg: color-mix(in srgb, var(--bs-btn-bg) 80%, black);
--bs-btn-active-border-color: var(--bs-btn-active-bg); --bs-btn-active-border-color: var(--bs-btn-active-bg);
font-weight: 500;
} }
.nav-link { [data-bs-theme=dark] .btn-primary {
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 80%, black);
--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);
}
.btn.btn-primary {
font-weight: 500;
border: none;
background-image: var(--bs-gradient);
padding: 7px 13px;
}
[data-bs-theme=light] .nav-link {
color: color-mix(in srgb, var(--bs-primary) 90%, black); color: color-mix(in srgb, var(--bs-primary) 90%, black);
} }
[data-bs-theme=dark] .nav-link, [data-bs-theme=dark] a {
color: var(--bs-link-color);
}
a:hover {
color: var(--bs-link-hover-color);
}
[data-bs-theme=light] .dropdown-item:hover, [data-bs-theme=light] .dropdown-item:focus {
--bs-dropdown-link-hover-bg: var(--bs-primary-subtle);
--bs-dropdown-link-hover-color: var(--bs-primary);
}
[data-bs-theme=dark] .dropdown-item:hover, [data-bs-theme=dark] .dropdown-item:focus {
--bs-dropdown-link-hover-bg: var(--bs-primary-subtle);
--bs-dropdown-link-hover-color: color-mix(in srgb, var(--bs-primary) 70%, white);
}
.dropdown-item.active, .dropdown-item:active { .dropdown-item.active, .dropdown-item:active {
background-color: var(--bs-primary); background-color: var(--bs-primary);
} }
.dropdown-menu {
padding: .5em;
}
.dropdown-item {
border-radius: var(--bs-border-radius);
padding-left: .6em;
padding-right: .6em;
}
.bg-hover-danger.dropdown-item:hover {
--bs-dropdown-link-hover-bg: var(--bs-danger-bg-subtle);
--bs-dropdown-link-hover-color: var(--bs-danger);
}
.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active {
--bs-dropdown-link-active-bg: var(--bs-danger);
background-color: red !important;
color: white !important;
--bs-dropdown-link-active-color: white;
}
.form-control:focus { .form-control:focus {
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent); 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); border-color: color-mix(in srgb, var(--bs-primary), transparent);
} }
.icon-link.dropdown-toggle::after { .no-arrow.dropdown-toggle::after {
border: none; border: none;
display: none; display: none;
} }
.markdown-content p {
margin: 0;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

86
static/js/color-modes.js Normal file
View file

@ -0,0 +1,86 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2024 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('#theme-icon-active')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const themeCheck = btnToActive.querySelector(`.theme-check`)
const iconOfActiveBtn = btnToActive.querySelector('i').className
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
document.querySelectorAll('.theme-check').forEach(element => {
element.classList.add('d-none')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
themeCheck.classList.remove('d-none')
activeThemeIcon.className = iconOfActiveBtn
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

View file

@ -2,17 +2,44 @@
{% block title %}Admin{% endblock %} {% block title %}Admin{% endblock %}
{% set adminLink = 'active' %} {% set adminLink = 'active' %}
{% block content %} {% block content %}
<h1>Admin panel</h1> <h1 class="mb-3">Admin panel</h1>
<div class="card"> <div id="response-container"></div>
<div class="card-body"> <form hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none">
<form action="{{ url_for('admin.index') }}" method="POST">
<input type="hidden" name="action" value="update_word_blacklist"> <input type="hidden" name="action" value="update_word_blacklist">
<div class="form-floating mb-3"> <div class="form-group mb-3">
<textarea id="blacklist" name="blacklist" style="height: 400px;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea> <label for="blacklist"><h2>Word blacklist</h2></label>
<label for="blacklist">Word blacklist</label> <textarea id="blacklist" name="blacklist" style="height: 400px; resize: vertical;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea>
</div> </div>
<button type="submit" class="btn btn-primary">Save</button> <button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</form> </form>
</div>
</div> {% 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.innerHTML = 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 %} {% endblock %}

View file

@ -9,25 +9,24 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.min.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/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> <link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
<title>{% block title %}{% endblock %} - {{ cfg.appName }}</title> <script src="{{ url_for('static', filename='js/color-modes.js') }}"></script>
<title>{% block title %}{% endblock %} - {{ const.appName }}</title>
</head> </head>
<body class="m-2"> <body class="m-2">
<div class="container-fluid"> <div class="container-fluid">
{% if logged_in %} {% if logged_in %}
<div class="d-flex justify-content-between"> <div class="d-flex justify-content-between align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
{% endif %} <ul class="nav nav-underline {% if not logged_in %}mb-3{% 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> <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 {{ 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> <li class="nav-item"><a class="nav-link {{ adminLink }}" id="admin-link" href="{{ url_for('admin.index') }}">Admin</a></li>
{%- endif -%}
</ul> </ul>
{% if logged_in %} <ul class="nav nav-underline m-0">
<ul class="nav nav-underline mt-2 mb-2"> <li><a class="nav-link p-0" href="{{ url_for('admin.logout') }}">Logout</a></li>
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul> </ul>
</div> </div>
{% else %}
<div class="mt-3 mb-3" aria-hidden="true"></div>
{% endif %} {% endif %}
{% with messages = get_flashed_messages(with_categories=True) %} {% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %} {% if messages %}
@ -40,12 +39,49 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script> <script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script> <script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
<!-- <script src="https://cdn.jsdelivr.net/npm/eruda" onload="eruda.init()"></script> -->
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}
<footer class="py-3 my-4"> <footer class="py-3 my-4 text-center d-flex justify-content-between align-items-center">
<p class="text-center text-body-secondary">CatAsk v{{ version }}</p> <div class="row">
<div class="dropdown bd-mode-toggle">
<button class="btn py-2 dropdown-toggle fs-5"
id="bd-theme"
type="button"
aria-expanded="false"
data-bs-toggle="dropdown"
data-bs-auto-close="outside"
aria-label="Toggle theme (auto)">
<i class="bi bi-circle-half my-1" id="theme-icon-active"></i>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
</button>
<ul class="dropdown-menu" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-sun-fill"></i> Light
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-moon-stars-fill"></i> Dark
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-circle-half"></i> Auto
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
</ul>
</div>
</div>
<div class="d-inline-block">
<a href="https://git.gay/mst/catask" target="_blank" class="text-body-secondary">{{ const.appName }} v{{ version }}{{ version_id }}</a>
</div>
</footer> </footer>
</div>
</body> </body>
</html> </html>

View file

@ -4,22 +4,45 @@
{% block content %} {% block content %}
{% if questions != [] %} {% if questions != [] %}
{% for question in questions %} {% for question in questions %}
<div class="card mb-2 mt-2 alert-placeholder" id="question-{{ question.id }}"> <div class="card mb-3 mt-3 alert-placeholder" id="question-{{ question.id }}">
<div class="card-body"> <div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <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> <h5 class="card-title mt-1 mb-1">{% 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> <h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div> </div>
<p>{{ question.content }}</p> <div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<form hx-post="{{ url_for('api.addAnswer', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none"> <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"> <div class="form-group d-grid gap-2">
<textarea class="form-control" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea> <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="submit" class="btn btn-primary">
<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> <span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<div class="modal fade" id="question-{{ question.id }}-modal" tabindex="-1" aria-labelledby="q-{{ question.id }}-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h1 class="modal-title fs-5" id="q-{{ question.id }}-modal-label">Confirmation</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this question?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Confirm</button>
</div>
</div>
</div>
</div>
{% endfor %} {% endfor %}
{% else %} {% else %}
<h2 class="text-center mt-5">Inbox is currently empty.</h2> <h2 class="text-center mt-5">Inbox is currently empty.</h2>
@ -27,6 +50,8 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const appendAlert = (elementId, message, type) => { const appendAlert = (elementId, message, type) => {
const alertPlaceholder = document.getElementById(elementId); const alertPlaceholder = document.getElementById(elementId);
const alertHtml = ` const alertHtml = `

View file

@ -2,49 +2,70 @@
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% set homeLink = 'active' %} {% set homeLink = 'active' %}
{% block content %} {% block content %}
<div class="mt-3 mb-5"> <div class="mt-5 mb-sm-2 mb-md-5">
<h2>Ask a question</h2> <h1 class="text-center fw-bold">{{ cfg.instanceTitle }}</h1>
<form hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none"> <h5 class="text-center fw-light">{{ cfg.instanceDesc }}</h5>
<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> </div>
{% for item in combined %} <div class="row">
<div class="card mt-2 mb-2" id="question-{{ item.question.id }}"> <div class="col-sm-4">
<div class="card-header"> <div class="mt-3 mb-5 sticky-md-top">
<div class="d-flex justify-content-between align-items-center"> <br>
<h5 class="card-title mt-1 mb-1">{% if item.question.from_who %}{{ item.question.from_who }}{% else %}Anonymous{% endif %}</h5> <h2>Ask a question</h2>
<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> <form class="d-lg-block" hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none">
<div class="form-group mb-2">
<input class="form-control" type="text" name="from_who" id="from_who" placeholder="Name (optional)">
</div>
<div class="form-group mb-2">
<textarea class="form-control" required name="question" id="question" placeholder="Write your question..."></textarea>
</div>
<div class="form-group mb-2">
<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">
</div>
<div class="form-group d-grid d-lg-flex justify-content-lg-end mt-3">
<button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Ask
</button>
</div>
</form>
<div id="response-container" class="mt-3"></div>
</div> </div>
<p class="card-text">{{ item.question.content }}</p>
</div> </div>
<div class="card-body"> <div class="col-sm-8">
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="text-decoration-none text-reset"> {% for item in combined %}
{% for answer in item.answers %} <div class="card mt-3 mb-3" id="question-{{ item.question.id }}">
<p class="mb-0">{{ answer.content }}</p> <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>
<div class="card-text markdown-content">{{ item.question.content | render_markdown }}</div>
</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 %}
<div class="markdown-content">{{ answer.content }}</div>
</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 no-arrow 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 bg-hover-danger text-danger" 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> </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 %} {% 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>
</div> </div>
{% endfor %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script>
@ -60,8 +81,9 @@
const appendAlert = (elementId, message, type, onclick) => { const appendAlert = (elementId, message, type, onclick) => {
const alertPlaceholder = document.querySelector(`#${elementId}`); const alertPlaceholder = document.querySelector(`#${elementId}`);
alertPlaceholder.innerHTML = '';
const alertHtml = ` const alertHtml = `
<div class="alert alert-${type} alert-dismissible" role="alert"> <div class="alert alert-${type} alert-dismissible mt-3" role="alert">
<div>${message}</div> <div>${message}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" onclick=${onclick}></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" onclick=${onclick}></button>
</div> </div>

View file

@ -1,26 +1,55 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}"{{ trimContent(question.content, 15) }}" - "{{ trimContent(answer.content, 15) }}"{% endblock %}
{% block content %} {% block content %}
<div class="card mt-2 mb-2"> <div class="container-md">
<div class="card-body"> <div class="card mt-2 mb-2" id="question-{{ question.id }}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center"> <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> <h5 class="card-title mt-1 mb-1">{% 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> <h6 class="card-subtitle fw-light text-body-secondary">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div> </div>
<p class="card-text">{{ question.content }}</p> <div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<p class="mb-0">{{ answer.content }}</p> <p class="mb-0">{{ answer.content }}</p>
</div> </div>
<div class="card-footer text-body-secondary d-flex justify-content-between align--center"> <div class="card-footer pt-0 pb-0 ps-3 pe-2 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span> <span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div class="dropdown"> <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> <button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li> <li><button class="dropdown-item" onclick="copy({{ question.id }})">Copy link</button></li>
{% if logged_in %} {% 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> <li><button class="bg-hover-danger text-danger 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 %} {% endif %}
</ul> </ul>
</div> </div>
</div> </div>
</div> </div>
</div>
{% 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 %} {% endblock %}