mirror of
https://codeberg.org/catask-org/catask.git
synced 2025-04-19 13:23:41 -05:00
1.0.0-alpha
This commit is contained in:
parent
bfa0fa79b2
commit
841edb2f22
15 changed files with 465 additions and 97 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,3 +1,5 @@
|
|||
venv/
|
||||
__pycache__/
|
||||
.env
|
||||
config.py
|
||||
word_blacklist.txt
|
||||
|
|
25
app.py
25
app.py
|
@ -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
4
config.example.py
Normal file
|
@ -0,0 +1,4 @@
|
|||
instanceTitle = "CatAsk"
|
||||
instanceDesc = 'Ask me something!'
|
||||
instanceImage = '/static/img/ca_screenshot.png'
|
||||
fullBaseUrl = 'https://<yourdomain>'
|
|
@ -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'
|
||||
|
|
|
@ -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'
|
||||
|
|
48
functions.py
48
functions.py
|
@ -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
|
||||
}
|
||||
|
|
|
@ -2,3 +2,5 @@ flask
|
|||
python-dotenv
|
||||
mysql-connector-python==8.2.0
|
||||
humanize
|
||||
mistune
|
||||
bleach
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
BIN
static/img/ca_screenshot.png
Normal file
BIN
static/img/ca_screenshot.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 158 KiB |
86
static/js/color-modes.js
Normal file
86
static/js/color-modes.js
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})()
|
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 = `
|
||||
|
|
|
@ -2,49 +2,70 @@
|
|||
{% block title %}Home{% endblock %}
|
||||
{% set homeLink = 'active' %}
|
||||
{% block content %}
|
||||
<div class="mt-3 mb-5">
|
||||
<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>
|
||||
<div class="row">
|
||||
<div class="col-sm-4">
|
||||
<div class="mt-3 mb-5 sticky-md-top">
|
||||
<br>
|
||||
<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">
|
||||
<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">
|
||||
<button type="submit" class="btn btn-primary">Ask</button>
|
||||
</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>
|
||||
{% for item in combined %}
|
||||
<div class="card mt-2 mb-2" id="question-{{ item.question.id }}">
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<p class="card-text">{{ item.question.content }}</p>
|
||||
<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 %}
|
||||
<p class="mb-0">{{ answer.content }}</p>
|
||||
<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 icon-link dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></a>
|
||||
<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" 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>
|
||||
<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>
|
||||
{% endfor %}
|
||||
</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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
Loading…
Add table
Reference in a new issue