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/
__pycache__/
.env
config.py
word_blacklist.txt

25
app.py
View file

@ -14,12 +14,12 @@ logged_in = False
load_dotenv()
app = Flask(cfg.appName)
app = Flask(const.appName)
app.secret_key = os.environ.get("APP_SECRET")
# -- blueprints --
api_bp = Blueprint('api', cfg.appName)
admin_bp = Blueprint('admin', cfg.appName)
api_bp = Blueprint('api', const.appName)
admin_bp = Blueprint('admin', const.appName)
# -- cli commands --
@ -83,7 +83,12 @@ def before_request():
@app.context_processor
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 --
@ -124,7 +129,7 @@ def inbox():
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)
return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str, trimContent=func.trimContent)
# -- admin client routes --
@ -160,8 +165,7 @@ def index():
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'))
return {'message': 'Changes saved!'}, 200
else:
blacklist = func.readPlainFile(const.blacklistFile)
return render_template('admin/index.html', blacklist=blacklist)
@ -230,16 +234,17 @@ def returnToInbox():
conn = func.connectToDb()
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()
question = {
'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("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()
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'

View file

@ -1,3 +1,6 @@
antiSpamFile = 'wordlist.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
import mistune
import humanize
import mysql.connector
import config as cfg
@ -34,7 +37,7 @@ def connectToDb():
user=dbUser,
password=dbPass,
database=dbName,
pool_name=f"{cfg.appName}-pool",
pool_name=f"{const.appName}-pool",
pool_size=32,
autocommit=True
)
@ -71,3 +74,46 @@ def readPlainFile(file, split=False):
def getRandomWord():
items = readPlainFile(const.antiSpamFile, split=True)
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

@ -2,3 +2,5 @@ flask
python-dotenv
mysql-connector-python==8.2.0
humanize
mistune
bleach

View file

@ -5,44 +5,123 @@
src: url("../fonts/rubik.woff2") format('woff2-variations');
}
:root, [data-bs-theme=light] {
--bs-primary: #6345d9;
--bs-primary-rgb: 214,107,26;
:root {
--bs-font-sans-serif: "Rubik", sans-serif;
--bs-link-color-rgb: var(--bs-primary-rgb);
--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-font-sans-serif: "Rubik", sans-serif;
--bs-dropdown-link-hover-bg: var(--bs-primary);
--bs-danger: #dc3545;
--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 {
--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-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 {
[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);
}
[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 {
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 {
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 {
.no-arrow.dropdown-toggle::after {
border: 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 %}
{% set adminLink = 'active' %}
{% block content %}
<h1>Admin panel</h1>
<div class="card">
<div class="card-body">
<form action="{{ url_for('admin.index') }}" method="POST">
<h1 class="mb-3">Admin panel</h1>
<div id="response-container"></div>
<form hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none">
<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 class="form-group mb-3">
<label for="blacklist"><h2>Word blacklist</h2></label>
<textarea id="blacklist" name="blacklist" style="height: 400px; resize: vertical;" placeholder="Word blacklist" class="form-control">{{ blacklist }}</textarea>
</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>
</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 %}

View file

@ -9,25 +9,24 @@
<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>
<script src="{{ url_for('static', filename='js/color-modes.js') }}"></script>
<title>{% block title %}{% endblock %} - {{ const.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">
<div class="d-flex justify-content-between align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<ul class="nav nav-underline {% if not logged_in %}mb-3{% endif %}">
<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 class="nav nav-underline m-0">
<li><a class="nav-link p-0" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul>
</div>
{% else %}
<div class="mt-3 mb-3" aria-hidden="true"></div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
@ -40,12 +39,49 @@
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/htmx.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 %}
<footer class="py-3 my-4">
<p class="text-center text-body-secondary">CatAsk v{{ version }}</p>
<footer class="py-3 my-4 text-center d-flex justify-content-between align-items-center">
<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>
</div>
</body>
</html>

View file

@ -4,22 +4,45 @@
{% 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="card mb-3 mt-3 alert-placeholder" id="question-{{ question.id }}">
<div class="card-header">
<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>
<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" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</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">
<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>
<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>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
</div>
</form>
</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 %}
{% else %}
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
@ -27,6 +50,8 @@
{% endblock %}
{% block scripts %}
<script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const appendAlert = (elementId, message, type) => {
const alertPlaceholder = document.getElementById(elementId);
const alertHtml = `

View file

@ -2,49 +2,70 @@
{% 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 class="mt-5 mb-sm-2 mb-md-5">
<h1 class="text-center fw-bold">{{ cfg.instanceTitle }}</h1>
<h5 class="text-center fw-light">{{ cfg.instanceDesc }}</h5>
</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 class="row">
<div class="col-sm-4">
<div class="mt-3 mb-5 sticky-md-top">
<br>
<h2>Ask a question</h2>
<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>
<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 class="col-sm-8">
{% for item in combined %}
<div class="card mt-3 mb-3" 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>
<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>
</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>
@ -60,8 +81,9 @@
const appendAlert = (elementId, message, type, onclick) => {
const alertPlaceholder = document.querySelector(`#${elementId}`);
alertPlaceholder.innerHTML = '';
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>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close" onclick=${onclick}></button>
</div>

View file

@ -1,26 +1,55 @@
{% extends 'base.html' %}
{% block title %}"{{ trimContent(question.content, 15) }}" - "{{ trimContent(answer.content, 15) }}"{% endblock %}
{% block content %}
<div class="card mt-2 mb-2">
<div class="card-body">
<div class="container-md">
<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">
<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>
</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>
</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>
<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">
<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>
<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 %}
</ul>
</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 %}