1.4.0-alpha

This commit is contained in:
mst 2024-09-19 18:29:12 +03:00
parent d4c2c7df88
commit 04662eaddc
19 changed files with 535 additions and 152 deletions

View file

@ -2,5 +2,6 @@ DB_HOST = 127.0.0.1
DB_NAME = catask DB_NAME = catask
DB_USER = DB_USER =
DB_PASS = DB_PASS =
DB_PORT =
ADMIN_PASSWORD = ADMIN_PASSWORD =
APP_SECRET = APP_SECRET =

22
CHANGELOG.md Normal file
View file

@ -0,0 +1,22 @@
## 1.4.0
### New features
* alerts show up as notifications in top-right corner of the screen
* ability to set rules
* live preview of some options in admin panel
* remaining character count under the question input box
* added alerts for "copy link" and "copy to clipboard" actions
* ctrl-enter shortcut to answer question, the same shortcut is used to save changes in admin panel
* live unyanswered question count in inbox
* option to disable confirmation when deleting questions from inbox
* instance description now supports markdown
### Fixes
* improved accessibility of some elements
* emoji support by switching to utf8mb4 charset
### Miscellaneous
* code cleanup
### Known bugs
* notification for "return to inbox" option triggers twice (visual bug)

View file

@ -40,6 +40,7 @@ First, rename `.env.example` to `.env` and `config.example.json` to `config.json
`DB_NAME` - database name `DB_NAME` - database name
`DB_USER` - database user `DB_USER` - database user
`DB_PASS` - database password `DB_PASS` - database password
`DB_PORT` - database port (usually 3306)
`ADMIN_PASSWORD` - password to access admin panel `ADMIN_PASSWORD` - password to access admin panel
`APP_SECRET` - application secret, generate one with this command: `python -c 'import secrets; print(secrets.token_hex())'` `APP_SECRET` - application secret, generate one with this command: `python -c 'import secrets; print(secrets.token_hex())'`
@ -55,9 +56,13 @@ If that doesn't work (e.g. tables are missing), try importing schema.sql into th
Use one of these commands to run CatAsk: `flask run` or `gunicorn -w 4 app:app` (if you have gunicorn installed) or `python app.py` Use one of these commands to run CatAsk: `flask run` or `gunicorn -w 4 app:app` (if you have gunicorn installed) or `python app.py`
If you want CatAsk to be accessible on a specific host address, specify a `--host` option to `flask run` (e.g. `--host 0.0.0.0`) If you want CatAsk to be accessible on a specific host address, specify a `--host` option to `flask run` (e.g. `--host 0.0.0.0`)
For debugging, run `flask run` with `--debug` argument (`flask run --debug`)
Admin login page is located at `https://yourdomain.tld/admin/login/` Admin login page is located at `https://yourdomain.tld/admin/login/`
Runs on `127.0.0.1:5000` (`flask run` or `python app.py`) or `127.0.0.1:8000` (`gunicorn -w 4 app:app`), may work in a production environment Runs on `127.0.0.1:5000` (`flask run` or `python app.py`) or `127.0.0.1:8000` (`gunicorn -w 4 app:app`), may work in a production environment
### Caddy ### Caddy
This repository contains an example Caddyfile that runs CatAsk on catask.localhost by reverse proxying it to 127.0.0.1:5000, you can modify it as needed This repository contains an example Caddyfile that runs CatAsk on catask.localhost by reverse proxying it to 127.0.0.1:5000, you can modify it as needed
## Updating
For instructions with updating from one version to another, check [UPDATE.md](https://git.gay/mst/catask/src/branch/main/UPDATE.md) file
Check [CHANGELOG.md](https://git.gay/mst/catask/src/branch/main/CHANGELOG.md) file for release notes

6
UPDATE.md Normal file
View file

@ -0,0 +1,6 @@
## 1.3.0 -> 1.4.0
first, pull the update from the repository: `git pull`
second, you'll need to update the database schema as it was changed slightly in this update, run these queries in mysql/mariadb console:
```USE <catask database name>;```
```ALTER TABLE answers CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;```
```ALTER TABLE questions CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_bin;```

20
app.py
View file

@ -48,6 +48,7 @@ def initDatabase():
user=os.environ.get("DB_USER"), user=os.environ.get("DB_USER"),
password=os.environ.get("DB_PASS"), password=os.environ.get("DB_PASS"),
host=os.environ.get("DB_HOST"), host=os.environ.get("DB_HOST"),
port=os.environ.get("DB_PORT"),
database='mysql' database='mysql'
) )
cursor = conn.cursor() cursor = conn.cursor()
@ -235,7 +236,7 @@ def addQuestion():
from_who = request.form.get('from_who', cfg['anonName']) from_who = request.form.get('from_who', cfg['anonName'])
question = request.form.get('question', '') question = request.form.get('question', '')
antispam = request.form.get('antispam', '') antispam = request.form.get('antispam', '')
# reserved for version 1.4.0 # reserved for version 1.5.0 or later
# private = request.form.get('private') # private = request.form.get('private')
if not question: if not question:
@ -358,6 +359,22 @@ def addAnswer():
return jsonify({'message': 'Answer added successfully!'}), 201 return jsonify({'message': 'Answer added successfully!'}), 201
# reserved for 1.6.0 or later
"""
@api_bp.route('/question_count/', methods=['GET'])
def questionCount():
conn = func.connectToDb()
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM questions WHERE answered=%s", (False,))
question_count = cursor.fetchone()
cursor.close()
conn.close()
return str(question_count[0])
"""
# unused
"""
@api_bp.route('/all_questions/', methods=['GET']) @api_bp.route('/all_questions/', methods=['GET'])
def listQuestions(): def listQuestions():
answered = request.args.get('answered', False) answered = request.args.get('answered', False)
@ -378,6 +395,7 @@ def listAnswers():
cursor.close() cursor.close()
conn.close() conn.close()
return jsonify(answers) return jsonify(answers)
"""
@api_bp.route('/view_question/', methods=['GET']) @api_bp.route('/view_question/', methods=['GET'])
def viewQuestion(): def viewQuestion():

View file

@ -9,5 +9,6 @@
"anonName": "Anonymous", "anonName": "Anonymous",
"lockInbox": false, "lockInbox": false,
"allowAnonQuestions": true, "allowAnonQuestions": true,
"showQuestionCount": false "showQuestionCount": false,
"noDeleteConfirm": false
} }

View file

@ -2,6 +2,6 @@ antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt' blacklistFile = 'word_blacklist.txt'
configFile = 'config.json' configFile = 'config.json'
appName = 'CatAsk' appName = 'CatAsk'
version = '1.3.0' version = '1.4.0'
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9 # id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
version_id = '-alpha' version_id = '-alpha'

View file

@ -43,6 +43,7 @@ dbHost = os.environ.get("DB_HOST")
dbUser = os.environ.get("DB_USER") dbUser = os.environ.get("DB_USER")
dbPass = os.environ.get("DB_PASS") dbPass = os.environ.get("DB_PASS")
dbName = os.environ.get("DB_NAME") dbName = os.environ.get("DB_NAME")
dbPort = os.environ.get("DB_PORT")
def createDatabase(cursor, dbName): def createDatabase(cursor, dbName):
try: try:
@ -58,6 +59,7 @@ def connectToDb():
user=dbUser, user=dbUser,
password=dbPass, password=dbPass,
database=dbName, database=dbName,
port=dbPort,
pool_name=f"{const.appName}-pool", pool_name=f"{const.appName}-pool",
pool_size=32, pool_size=32,
autocommit=True autocommit=True

View file

@ -1,5 +1,7 @@
# CatAsk stable roadmap # CatAsk stable roadmap
* [ ] content warnings
* [x] move to toastify for alerts
* [x] make stuff more accessible * [x] make stuff more accessible
* [ ] implement private questions * [ ] implement private questions
* [x] deleting answered questions OR returning them to inbox like retrospring does * [x] deleting answered questions OR returning them to inbox like retrospring does

View file

@ -3,7 +3,7 @@ CREATE TABLE IF NOT EXISTS answers (
question_id INT NOT NULL, question_id INT NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL content TEXT NOT NULL
) ENGINE=InnoDB; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS questions ( CREATE TABLE IF NOT EXISTS questions (
id INT PRIMARY KEY AUTO_INCREMENT, id INT PRIMARY KEY AUTO_INCREMENT,
@ -13,9 +13,9 @@ CREATE TABLE IF NOT EXISTS questions (
answered BOOLEAN NOT NULL DEFAULT FALSE, answered BOOLEAN NOT NULL DEFAULT FALSE,
answer_id INT, answer_id INT,
pinned BOOLEAN NOT NULL DEFAULT FALSE pinned BOOLEAN NOT NULL DEFAULT FALSE
-- below is reserved for version 1.4.0 -- below is reserved for version 1.5.0 or later
-- private BOOLEAN NOT NULL DEFAULT FALSE -- private BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE questions ALTER TABLE questions
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE; ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;

View file

@ -15,7 +15,7 @@
[data-bs-theme=light] { [data-bs-theme=light] {
--bs-primary: #6345d9; --bs-primary: #6345d9;
--bs-primary-rgb: 99, 69, 217; --bs-primary-rgb: 99, 69, 217;
--bs-primary-subtle: color-mix(in srgb, var(--bs-primary) 10%, transparent); --bs-primary-bg-subtle: color-mix(in srgb, var(--bs-primary) 20%, 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-danger: #dc3545; --bs-danger: #dc3545;
--bs-danger-bg-subtle: #f8e6e8; --bs-danger-bg-subtle: #f8e6e8;
@ -25,6 +25,7 @@
--bs-basic-btn-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, black); --bs-basic-btn-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, black);
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, black); --bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, black);
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, black); --bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, black);
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), black);
} }
[data-bs-theme=dark] { [data-bs-theme=dark] {
@ -32,7 +33,7 @@
--bs-body-bg-rgb: 32, 32, 32; --bs-body-bg-rgb: 32, 32, 32;
--bs-primary: #7f62f0; --bs-primary: #7f62f0;
--bs-primary-rgb: 127,98,240; --bs-primary-rgb: 127,98,240;
--bs-primary-subtle: color-mix(in srgb, var(--bs-primary) 10%, transparent); --bs-primary-bg-subtle: color-mix(in srgb, var(--bs-primary) 20%, transparent);
--bs-danger-bg-subtle: #2c0b0e; --bs-danger-bg-subtle: #2c0b0e;
--bs-link-color: color-mix(in srgb, var(--bs-primary) 65%, white); --bs-link-color: color-mix(in srgb, var(--bs-primary) 65%, white);
--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);
@ -40,6 +41,7 @@
--bs-basic-btn-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, white); --bs-basic-btn-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, white);
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, white); --bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, white);
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, white); --bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, white);
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), white);
} }
[data-bs-theme=light] .light-invert, [data-bs-theme=light] .light-invert,
@ -47,11 +49,21 @@
filter: invert(); filter: invert();
} }
.btn-check:checked + .btn-secondary, .btn-check:checked + .btn-outline-secondary {
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong) !important;
--bs-btn-active-color: var(--bs-body-color);
background-color: var(--bs-btn-active-bg) !important;
}
.btn-outline-secondary {
--bs-btn-color: var(--bs-body-color);
--bs-btn-border-color: var(--bs-basic-btn-active-bg-strong);
}
.btn { .btn {
--bs-btn-border-radius: .5rem; --bs-btn-border-radius: .5rem;
} }
.btn:not(.btn-primary,.btn-danger,.btn-success):focus-visible { .btn.disabled, .btn:disabled, fieldset:disabled .btn {
outline: revert; background-color: var(--bs-btn-disabled-bg);
} }
[data-bs-theme=light] .btn-primary { [data-bs-theme=light] .btn-primary {
@ -61,15 +73,17 @@
--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);
--bs-btn-disabled-bg: color-mix(in srgb, var(--bs-primary) 70%, white);
} }
[data-bs-theme=dark] .btn-primary { [data-bs-theme=dark] .btn-primary {
--bs-btn-bg: color-mix(in srgb, var(--bs-primary) 80%, black); --bs-btn-bg: color-mix(in srgb, var(--bs-primary) 65%, black);
--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);
--bs-btn-disabled-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
} }
.btn.btn-primary { .btn.btn-primary {
@ -79,17 +93,17 @@
padding: 7px 13px; padding: 7px 13px;
} }
.btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):hover, .btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):focus { .btn:not(.btn-primary, .btn-outline-warning, .btn-success, .btn-secondary, .btn-danger,.btn-outline-danger):hover, .btn:not(.btn-primary, .btn-success, .btn-secondary, .btn-outline-warning, .btn-danger,.btn-outline-danger):focus {
background-color: var(--bs-basic-btn-hover-bg); background-color: var(--bs-basic-btn-hover-bg);
} }
.btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):active { .btn:not(.btn-primary, .btn-outline-warning, .btn-success, .btn-secondary, .btn-danger,.btn-outline-danger):active {
background-color: var(--bs-basic-btn-active-bg); background-color: var(--bs-basic-btn-active-bg);
} }
.card-footer .btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):hover, .card-footer .btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):focus { .card-footer .btn:not(.btn-primary, .btn-outline-warning, .btn-success, .btn-secondary, .btn-danger, .btn-outline-danger):hover, .card-footer .btn:not(.btn-primary, .btn-success, .btn-outline-warning, .btn-secondary, .btn-danger,.btn-outline-danger):focus {
background-color: var(--bs-basic-btn-hover-bg-strong) !important; background-color: var(--bs-basic-btn-hover-bg-strong) !important;
color: var(--bs-body-color) !important; color: var(--bs-body-color) !important;
} }
.card-footer .btn:not(.btn-primary, .btn-success, .btn-danger,.btn-outline-danger):active { .card-footer .btn:not(.btn-primary, .btn-outline-warning, .btn-secondary, .btn-success, .btn-danger, .btn-outline-danger):active {
background-color: var(--bs-basic-btn-active-bg-strong) !important; background-color: var(--bs-basic-btn-active-bg-strong) !important;
} }
.btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active { .btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active {
@ -113,12 +127,12 @@ a:hover {
} }
[data-bs-theme=light] .dropdown-item:hover, [data-bs-theme=light] .dropdown-item:focus { [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-bg: color-mix(in srgb, var(--bs-primary) 10%, transparent);
--bs-dropdown-link-hover-color: var(--bs-primary); --bs-dropdown-link-hover-color: var(--bs-primary);
} }
[data-bs-theme=dark] .dropdown-item:hover, [data-bs-theme=dark] .dropdown-item:focus { [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-bg: color-mix(in srgb, var(--bs-primary) 10%, transparent);
--bs-dropdown-link-hover-color: color-mix(in srgb, var(--bs-primary) 70%, white); --bs-dropdown-link-hover-color: color-mix(in srgb, var(--bs-primary) 70%, white);
} }
@ -154,9 +168,11 @@ a:hover {
--bs-dropdown-link-active-color: white; --bs-dropdown-link-active-color: white;
} }
.form-control:focus, .form-check-input:focus { .form-control:focus, .form-check-input:focus,
.accordion-button:focus, .btn:focus-visible {
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);
outline: 0;
} }
.list-group-item.active { .list-group-item.active {
@ -176,6 +192,9 @@ a:hover {
.markdown-content p { .markdown-content p {
margin: 0; margin: 0;
} }
.markdown-content ol {
margin-bottom: 0;
}
.htmx-indicator { .htmx-indicator {
display: none; display: none;
@ -190,3 +209,24 @@ a:hover {
border-color: var(--bs-primary); border-color: var(--bs-primary);
} }
.nav-underline .nav-link.active, .nav-underline .show > .nav-link {
font-weight: 500;
}
.spinner-border-sm {
--bs-spinner-border-width: .15em;
}
/* #dee2e6 */
[data-bs-theme="dark"] .accordion-button::after {
--bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23dee2e6'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
--bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23d7d8ea'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
}
#rules li:not(:first-child, :last-child) {
margin: .5em 0;
}
#rules li::marker {
font-weight: bold;
font-size: 1.5em;
}

91
static/css/toastify.css Normal file
View file

@ -0,0 +1,91 @@
/*!
* Toastify js 1.12.0
* https://github.com/apvarun/toastify-js
* @license MIT licensed
*
* Copyright (C) 2018 Varun A P
*/
.toastify {
/* padding: 12px 20px; */
/* color: #ffffff; */
/* display: inline-block; */
/* box-shadow: 0 3px 6px -1px rgba(0, 0, 0, 0.12), 0 10px 36px -4px rgba(77, 96, 232, 0.3); */
/* background: -webkit-linear-gradient(315deg, #73a5ff, #5477f5); */
/* background: linear-gradient(135deg, #73a5ff, #5477f5); */
/* padding: .5em .8em; */
position: fixed;
opacity: 0;
transition: all 0.4s cubic-bezier(0.215, 0.61, 0.355, 1);
/* border-radius: 2px; */
/* cursor: pointer; */
/* text-decoration: none; */
max-width: calc(50% - 20px);
z-index: 2147483647;
}
.toastify.on {
opacity: 1;
}
.toast-close {
background: transparent;
border: 0;
color: white;
cursor: pointer;
font-family: inherit;
font-size: 1em;
opacity: 0.4;
padding: 0 5px;
}
.toastify-right {
right: 15px;
}
.toastify-left {
left: 15px;
}
.toastify-top {
top: -150px;
}
.toastify-bottom {
bottom: -150px;
}
.toastify-rounded {
border-radius: 25px;
}
.toastify-avatar {
width: 1.5em;
height: 1.5em;
margin: -7px 5px;
border-radius: 2px;
}
.toastify-center {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
max-width: -moz-fit-content;
}
@media only screen and (max-width: 360px) {
.toastify-right, .toastify-left {
margin-left: auto;
margin-right: auto;
left: 0;
right: 0;
max-width: fit-content;
}
}
@media screen and (max-width: 700px) {
.toastify {
max-width: 100%;
}
}

6
static/js/marked.min.js vendored Normal file

File diff suppressed because one or more lines are too long

15
static/js/toastify.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View file

@ -1,28 +1,47 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Admin{% endblock %} {% block title %}Admin{% endblock %}
{% set adminLink = 'active' %} {% set adminLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %} {% block content %}
<h1 class="mb-3">Admin panel</h1> <h1 class="mb-3">Admin panel</h1>
<!-- this is actually not used anymore, but htmx requires a valid target so we have to use it -->
<div id="response-container"></div> <div id="response-container"></div>
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none"> <a class="btn d-lg-none mb-2" href="#preview">Skip to preview</a>
<form hx-trigger="click from:#saveConfig, keyup[ctrlKey&&key=='Enter']" hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<a class="visually-hidden-focusable" href="#preview">Skip to preview</a>
<div class="row">
<div class="col-sm-6">
<h2 id="instance">Instance</h2> <h2 id="instance">Instance</h2>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label class="form-label" for="instance.title">Title <small class="text-secondary">(e.g. My question box)</small></label> <label class="form-label" for="instance.title">Title <small class="text-body-secondary">(e.g. My question box)</small></label>
<input type="text" id="instance.title" name="instance.title" value="{{ cfg.instance.title }}" class="form-control"> <input type="text" id="instance.title" name="instance.title" value="{{ cfg.instance.title }}" oninput="updateText('instance.title', 'title')" class="form-control">
<p class="form-text">Title of this CatAsk instance</p> <p class="form-text">Title of this CatAsk instance</p>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label class="form-label" for="instance.description">Description <small class="text-secondary">(e.g. Ask me a question!)</small></label> <label class="form-label d-flex flex-column flex-lg-row align-items-lg-center justify-content-between" for="instance.description">
<input type="text" id="instance.description" name="instance.description" value="{{ cfg.instance.description }}" class="form-control"> <span>Description <small class="text-body-secondary">(e.g. Ask me a question!)</small></span>
<small class="text-body-secondary"><i class="bi bi-markdown me-1"></i> Markdown supported</small>
</label>
<textarea id="instance.description" name="instance.description" oninput="updateText('instance.description', 'desc')" class="form-control" style="height: 200px; resize: vertical;">{{ cfg.instance.description }}</textarea>
<p class="form-text">Description of this CatAsk instance</p> <p class="form-text">Description of this CatAsk instance</p>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<label class="form-label" for="instance.image">Relative image path <small class="text-secondary">(default: /static/img/ca_screenshot.png)</small></label> <label class="form-label d-flex flex-column flex-lg-row align-items-lg-center justify-content-between" for="instance.rules">
<span>Rules</span>
<small class="text-body-secondary"><i class="bi bi-markdown me-1"></i> Markdown supported</small>
</label>
<textarea id="instance.rules" name="instance.rules" class="form-control" style="height: 200px; resize: vertical;" oninput="updateText('instance.rules', 'rules-content')">{{ cfg.instance.rules }}</textarea>
<p class="form-text">Rules of this CatAsk instance</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="instance.image">Relative image path <small class="text-body-secondary">(default: /static/img/ca_screenshot.png)</small></label>
<input type="text" id="instance.image" name="instance.image" value="{{ cfg.instance.image }}" class="form-control"> <input type="text" id="instance.image" name="instance.image" value="{{ cfg.instance.image }}" class="form-control">
<p class="form-text">Image that's going to be used in a link preview</p> <p class="form-text">Image that's going to be used in a link preview</p>
</div> </div>
<div class="form-group mb-4"> <div class="form-group mb-4">
<label class="form-label" for="instance.fullBaseUrl">Base URL <small class="text-secondary">(e.g. https://ask.example.com)</small></label> <label class="form-label" for="instance.fullBaseUrl">Base URL <small class="text-body-secondary">(e.g. https://ask.example.com)</small></label>
<input type="text" id="instance.fullBaseUrl" name="instance.fullBaseUrl" value="{{ cfg.instance.fullBaseUrl }}" class="form-control"> <input type="text" id="instance.fullBaseUrl" name="instance.fullBaseUrl" value="{{ cfg.instance.fullBaseUrl }}" class="form-control">
<p class="form-text">Full URL to homepage of this CatAsk instance without a trailing slash</p> <p class="form-text">Full URL to homepage of this CatAsk instance without a trailing slash</p>
</div> </div>
@ -37,6 +56,17 @@
<input type="text" id="anonName" name="anonName" value="{{ cfg.anonName }}" class="form-control"> <input type="text" id="anonName" name="anonName" value="{{ cfg.anonName }}" class="form-control">
<p class="form-text">This name will be used for questions asked to you by anonymous users</p> <p class="form-text">This name will be used for questions asked to you by anonymous users</p>
</div> </div>
<!-- reserved for 1.5.0 or later -->
<!-- <h3>Info block layout</h3> -->
<!-- <div class="btn-group mb-4 w-100" role="group" aria-label="Info block layout toggle button group"> -->
<!-- <input type="radio" class="btn-check" name="infoBlockLayout" id="columnLayout" autocomplete="off" checked> -->
<!-- <label class="btn btn-outline-secondary w-100" for="columnLayout">Column</label> -->
<!-- -->
<!-- <input type="radio" class="btn-check" name="infoBlockLayout" id="rowLayout" autocomplete="off"> -->
<!-- <label class="btn btn-outline-secondary w-100" for="rowLayout">Row</label> -->
<!-- </div> -->
<div class="form-check mb-2"> <div class="form-check mb-2">
<input <input
class="form-check-input" class="form-check-input"
@ -59,6 +89,17 @@
<input type="hidden" id="allowAnonQuestions" name="allowAnonQuestions" value="{{ cfg.allowAnonQuestions }}"> <input type="hidden" id="allowAnonQuestions" name="allowAnonQuestions" value="{{ cfg.allowAnonQuestions }}">
<label for="_allowAnonQuestions" class="form-check-label">Allow anonymous questions</label> <label for="_allowAnonQuestions" class="form-check-label">Allow anonymous questions</label>
</div> </div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
name="_noDeleteConfirm"
id="_noDeleteConfirm"
value="{{ cfg.noDeleteConfirm }}"
{% if cfg.noDeleteConfirm == true %}checked{% endif %}>
<input type="hidden" id="noDeleteConfirm" name="noDeleteConfirm" value="{{ cfg.noDeleteConfirm }}">
<label for="_noDeleteConfirm" class="form-check-label">Disable confirmation when deleting questions</label>
</div>
<div class="form-check mb-3"> <div class="form-check mb-3">
<input <input
class="form-check-input" class="form-check-input"
@ -71,7 +112,7 @@
<label for="_showQuestionCount" class="form-check-label">Show question count in homepage</label> <label for="_showQuestionCount" class="form-check-label">Show question count in homepage</label>
</div> </div>
<div class="form-group mb-3"> <div class="form-group mb-3">
<button type="submit" class="btn btn-primary mt-3"> <button type="submit" class="btn btn-primary mt-3" id="saveConfig">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span> <span class="visually-hidden" role="status">Loading...</span>
Save Save
@ -79,50 +120,92 @@
</div> </div>
</form> </form>
<hr class="mt-4 mb-4"> <hr class="mt-4 mb-4">
<form hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none"> <form hx-trigger="click from:#save-blacklist, keyup[ctrlKey&&key=='Enter']" hx-post="{{ url_for('admin.index') }}" hx-target="#response-container" hx-swap="none">
<input type="hidden" name="action" value="update_word_blacklist"> <input type="hidden" name="action" value="update_word_blacklist">
<div class="form-group mb-3"> <div class="form-group mb-3">
<label class="form-label" for="blacklist_cat"><h2 id="blacklist">Word blacklist</h2></label> <label class="form-label" for="blacklist_cat"><h2 id="blacklist">Word blacklist</h2></label>
<p class="text-secondary">Blacklisted words for questions; one word per line</p> <p class="text-body-secondary">Blacklisted words for questions; one word per line</p>
<textarea id="blacklist_cat" name="blacklist" style="height: 300px; resize: vertical;" class="form-control">{{ blacklist }}</textarea> <textarea id="blacklist_cat" name="blacklist" style="height: 300px; resize: vertical;" class="form-control">{{ blacklist }}</textarea>
<button type="submit" class="btn btn-primary mt-3"> <button type="submit" class="btn btn-primary mt-3" id="save-blacklist">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span> <span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span> <span class="visually-hidden" role="status">Loading...</span>
Save Save
</button> </button>
</div> </div>
</form> </form>
</div>
<div class="col-sm-6" id="preview">
<h2>Preview</h2>
<button class="text-warning-emphasis d-inline-flex align-items-center btn btn-sm mw-100" type="button" data-bs-toggle="collapse" data-bs-target="#preview-warning"><i class="bi bi-exclamation-triangle fs-5 me-2"></i> The preview might not always be accurate</button>
<div class="collapse" id="preview-warning">
<small class="mt-2 text-body-secondary">The preview is not guaranteed to render the same as on other pages, because it uses a different renderer (<b>marked (js)</b> while everything else uses <b>mistune (python)</b>)<br>The reason for this is live markdown rendering support</small>
</div>
<h3 class="h1 text-center fw-bold mt-4" id="title">{{ cfg.instance.title }}</h3>
{% autoescape off %}
<h4 class="h5 text-center fw-light" id="desc">{{ cfg.instance.description | render_markdown }}</h4>
{% endautoescape %}
<div class="m-auto col-sm-10">
<div class="accordion" id="rules-accordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rules" aria-expanded="false" aria-controls="collapseTwo">
<i class="bi bi-exclamation-triangle me-2 fs-4"></i> Rules
</button>
</h2>
<div id="rules" class="accordion-collapse collapse" data-bs-parent="#rules-accordion">
<div class="accordion-body">
<div class="markdown-content">{{ cfg.instance.rules | render_markdown }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/marked.min.js') }}"></script>
<script> <script>
// fix handling checkboxes marked.use({
document.querySelectorAll('.form-check-input').forEach(function(checkbox) { breaks: true,
gfm: true,
});
function updateText(input, element) {
const inputEl = document.getElementById(input);
const replaceEl = document.getElementById(element);
replaceEl.innerHTML = marked.parse(inputEl.value);
}
</script>
<script>
// fix handling checkboxes and radios
document.querySelectorAll('.form-check-input, .btn-check').forEach(function(checkbox) {
checkbox.addEventListener('change', function() { checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False'; checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
}); });
}); });
</script> </script>
<script> <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) { document.addEventListener('htmx:afterRequest', function(event) {
console.log("reached event listener");
const jsonResponse = event.detail.xhr.response; const jsonResponse = event.detail.xhr.response;
if (jsonResponse) { if (jsonResponse) {
const parsed = JSON.parse(jsonResponse); const parsed = JSON.parse(jsonResponse);
const msgType = event.detail.successful ? 'success' : 'error'; const alertType = event.detail.successful ? 'success' : 'danger';
const targetElementId = event.detail.target.id; message = event.detail.successful ? parsed.message : parsed.error;
appendAlert(targetElementId, parsed.message, msgType); if (event.detail.target.id != "question-count") {
Toastify({
text: message,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-${alertType} shadow alert-dismissible`,
close: true
}).showToast();
} }
}) }
});
</script> </script>
{% endblock %} {% endblock %}

View file

@ -36,7 +36,8 @@
<meta property="twitter:image" content="{{ metadata.image }}" /> <meta property="twitter:image" content="{{ metadata.image }}" />
<script src="{{ url_for('static', filename='js/color-modes.js') }}"></script> <script src="{{ url_for('static', filename='js/color-modes.js') }}"></script>
<title>{% block title %}{% endblock %} | {{ const.appName }}</title> {% block additionalHeadItems %}{% endblock %}
<title>{% block title %}{% endblock %} | {{ cfg.instanceTitle }}</title>
</head> </head>
<body class="ms-2 me-2 mb-2"> <body class="ms-2 me-2 mb-2">
<a class="visually-hidden-focusable" href="#main-content">Skip to content</a> <a class="visually-hidden-focusable" href="#main-content">Skip to content</a>
@ -47,6 +48,7 @@
<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 %} {% 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>
<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 %} {% endif %}
</ul> </ul>

View file

@ -1,13 +1,16 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Inbox {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %} {% block title %}Inbox {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %}
{% set inboxLink = 'active' %} {% set inboxLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %} {% block content %}
{% if questions != [] %} {% if questions != [] %}
<h3 class="fs-4">{{ len(questions) }} <span class="fw-light">question(s)</span></h3> <h3 class="fs-4"><span id="question-count-inbox">{{ len(questions) }}</span> <span class="fw-light">question(s)</span></h3>
<div class="row"> <div class="row">
{% for question in questions %} {% for question in questions %}
<div class="col-sm-8 m-auto"> <div class="col-sm-8 m-auto">
<div class="card mb-3 mt-3 alert-placeholder" id="question-{{ question.id }}"> <div class="card mb-3 mt-3 alert-placeholder question" id="question-{{ question.id }}">
<div class="card-header"> <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-1 mb-1"> <h5 class="card-title mt-1 mb-1">
@ -19,7 +22,7 @@
</h5> </h5>
<h6 class="card-subtitle fw-light text-body-secondary"> <h6 class="card-subtitle fw-light text-body-secondary">
{# {#
reserved for version 1.4.0 or later reserved for version 1.5.0 or later
{% if question.private %} {% if question.private %}
<span class="me-2"><i class="bi bi-lock"></i> <span class="fw-medium" data-bs-toggle="tooltip" data-bs-title="This question was asked privately">Private</span></span> <span class="me-2"><i class="bi bi-lock"></i> <span class="fw-medium" data-bs-toggle="tooltip" data-bs-title="This question was asked privately">Private</span></span>
@ -31,22 +34,27 @@
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div> <div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div> </div>
<div class="card-body"> <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-trigger="click from:#answer-btn-{{ question.id }}, keyup[ctrlKey&&key=='Enter']" hx-post="{{ url_for('api.addAnswer', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">
<div class="form-group d-sm-grid d-md-block gap-2"> <div class="form-group d-sm-grid d-md-block gap-2">
<label for="answer-{{ question.id }}" class="visually-hidden-focusable">Write your answer...</label> <label for="answer-{{ question.id }}" class="visually-hidden-focusable">Write your answer...</label>
<textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea> <textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
<div class="d-flex flex-column flex-md-row-reverse gap-2"> <div class="d-flex flex-column flex-md-row-reverse gap-2">
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary" id="answer-btn-{{ question.id }}">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span> <span class="visually-hidden" role="status">Loading...</span>
Answer Answer
</button> </button>
{% if not cfg.noDeleteConfirm %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button> <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
{% else %}
<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>
{% endif %}
</div> </div>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
{% if not cfg.noDeleteConfirm %}
<div class="modal fade" id="question-{{ question.id }}-modal" tabindex="-1" aria-labelledby="q-{{ question.id }}-modal-label" aria-hidden="true"> <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-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
@ -55,7 +63,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<p>Are you sure you want to delete this question?</p> <p class="mb-0">Are you sure you want to delete this question?</p>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn" data-bs-dismiss="modal">Cancel</button> <button type="button" class="btn" data-bs-dismiss="modal">Cancel</button>
@ -64,6 +72,7 @@
</div> </div>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
@ -72,28 +81,34 @@
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script> <script>
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
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) { document.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response; const jsonResponse = event.detail.xhr.response;
if (jsonResponse) { if (jsonResponse) {
const parsed = JSON.parse(jsonResponse); const parsed = JSON.parse(jsonResponse);
const msgType = event.detail.successful ? 'success' : 'error'; const alertType = event.detail.successful ? 'success' : 'danger';
msgType = event.detail.successful ? parsed.message : parsed.error;
const targetElementId = event.detail.target.id; const targetElementId = event.detail.target.id;
appendAlert(targetElementId, parsed.message, msgType); if (targetElementId != "question-count") {
document.getElementById(targetElementId).outerHTML = '';
Toastify({
text: msgType,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-${alertType} shadow alert-dismissible`,
close: true
}).showToast();
const questions = document.querySelectorAll('.question');
const count = questions.length;
document.getElementById('question-count-inbox').textContent = count;
document.title = `Inbox (${count}) | {{ const.appName }}`;
}
} }
}) })
</script> </script>

View file

@ -1,12 +1,33 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Home{% endblock %} {% block title %}Home{% endblock %}
{% set homeLink = 'active' %} {% set homeLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %} {% block content %}
<div class="mt-5 mb-sm-2 mb-md-5"> <div class="mt-5 mb-sm-2 mb-md-5">
<div>
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1> <h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
{% autoescape off %} {% autoescape off %}
<h2 class="h5 text-center fw-light">{{ cfg.instance.description }}</h2> <h2 class="h5 text-center fw-light">{{ cfg.instance.description | render_markdown }}</h2>
{% endautoescape %} {% endautoescape %}
</div>
<div class="m-auto col-sm-6">
<div class="accordion" id="rules-accordion">
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rules" aria-expanded="false" aria-controls="collapseTwo">
<i class="bi bi-exclamation-triangle me-2 fs-4"></i> Rules
</button>
</h2>
<div id="rules" class="accordion-collapse collapse" data-bs-parent="#rules-accordion">
<div class="accordion-body">
<div class="markdown-content">{{ cfg.instance.rules | render_markdown }}</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-sm-{% if combined %}4{% else %}8{% endif %}{% if not combined %} m-auto{% endif %}"> <div class="col-sm-{% if combined %}4{% else %}8{% endif %}{% if not combined %} m-auto{% endif %}">
@ -16,12 +37,13 @@
<h2>Ask a question</h2> <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"> <form class="d-lg-block" hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none">
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<input {% if cfg.allowAnonQuestions == false %}required{% endif %} class="form-control" type="text" name="from_who" id="from_who" placeholder="Name {% if cfg.allowAnonQuestions == true %}(optional){% else %}(required){% endif %}"> <input maxlength="{{ cfg.charLimit }}" {% if cfg.allowAnonQuestions == false %}required{% endif %} class="form-control" type="text" name="from_who" id="from_who" placeholder="Name {% if cfg.allowAnonQuestions == true %}(optional){% else %}(required){% endif %}">
<label for="from_who">Name {% if cfg.allowAnonQuestions == true %}(optional){% else %}(required){% endif %}</label> <label for="from_who">Name {% if cfg.allowAnonQuestions == true %}(optional){% else %}(required){% endif %}</label>
</div> </div>
<div class="form-floating mb-2"> <div class="form-floating mb-2">
<textarea class="form-control" style="height: 100px;" required name="question" id="question" placeholder="Write your question..."></textarea> <textarea maxlength="{{ cfg.charLimit }}" class="form-control" style="height: 100px;" required name="question" id="question" placeholder="Write your question..."></textarea>
<label for="question">Write your question...</label> <label for="question">Write your question...</label>
<p class="text-end mt-1 small d-flex align-itemcenter justify-content-between"><span class="text-body-secondary"><i class="bi bi-markdown me-1"></i> Markdown supported</span> <span id="charCount">{{ cfg.charLimit }}</span></p>
</div> </div>
<div class="form-group mb-2"> <div class="form-group mb-2">
<label for="antispam">Anti-spam: please enter the word <code class="text-uppercase">{{ getRandomWord() }}</code> in lowercase</label> <label for="antispam">Anti-spam: please enter the word <code class="text-uppercase">{{ getRandomWord() }}</code> in lowercase</label>
@ -30,7 +52,7 @@
<div class="form-group d-grid d-lg-flex align-items-center justify-content-lg-end mt-3"> <div class="form-group d-grid d-lg-flex align-items-center justify-content-lg-end mt-3">
{# {#
<div class="form-check mb-0 w-100"> <div class="form-check mb-0 w-100">
reserved for version 1.4.0 reserved for version 1.5.0 or later
<input <input
class="form-check-input" class="form-check-input"
type="checkbox" type="checkbox"
@ -40,8 +62,8 @@
<label for="_private" class="form-check-label">Ask privately</label> <label for="_private" class="form-check-label">Ask privately</label>
</div> </div>
#} #}
<button type="submit" class="btn btn-primary col-sm-4"> <button type="submit" class="btn btn-primary col-sm-4" id="ask-btn">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span> <span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span> <span class="visually-hidden" role="status">Loading...</span>
Ask Ask
</button> </button>
@ -184,6 +206,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script> <script>
// fix handling checkboxes // fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) { document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
@ -194,11 +217,50 @@
</script> </script>
<script> <script>
function copy(questionId) { function copy(questionId) {
navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/") navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/");
Toastify({
text: "Successfully copied link to clipboard!",
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-success shadow alert-dismissible`,
close: true
}).showToast();
} }
function copyFull(text) { function copyFull(text) {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
Toastify({
text: "Successfully copied text to clipboard!",
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-success shadow alert-dismissible`,
close: true
}).showToast();
} }
const input = document.getElementById('question');
const charCount = document.getElementById('charCount');
function updateCharCount() {
const maxLength = input.getAttribute('maxlength');
const currentLength = input.value.length;
const remaining = maxLength - currentLength;
charCount.textContent = remaining;
if (remaining <= 50) {
charCount.classList.add('text-warning');
} else {
charCount.classList.remove('text-warning');
}
if (remaining <= 10) {
charCount.classList.add('text-danger');
charCount.classList.remove('text-warning');
} else {
charCount.classList.remove('text-danger');
}
}
input.addEventListener('input', updateCharCount);
function shareOnFediverse(questionId, contentToShare) { function shareOnFediverse(questionId, contentToShare) {
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim(); const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`; const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
@ -213,30 +275,42 @@ function shareOnFediverse(questionId, contentToShare) {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]'); const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl)); const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const appendAlert = (elementId, message, type, onclick) => { document.addEventListener('htmx:beforeRequest', function(event) {
const alertPlaceholder = document.querySelector(`#${elementId}`); if (event.detail.target.id != "question-count") {
alertPlaceholder.innerHTML = ''; document.getElementById('ask-btn').setAttribute('disabled', true);
const alertHtml = `
<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>
`;
alertPlaceholder.innerHTML = alertHtml;
} }
});
document.addEventListener('htmx:afterRequest', function(event) { document.addEventListener('htmx:afterRequest', function(event) {
if (event.detail.target.id != "question-count") {
document.getElementById('ask-btn').removeAttribute('disabled');
}
const jsonResponse = event.detail.xhr.response; const jsonResponse = event.detail.xhr.response;
if (jsonResponse) { if (jsonResponse) {
const parsed = JSON.parse(jsonResponse); const parsed = JSON.parse(jsonResponse);
const alertType = event.detail.successful ? 'success' : 'danger'; const alertType = event.detail.successful ? 'success' : 'danger';
msgType = event.detail.successful ? parsed.message : parsed.error; msgType = event.detail.successful ? parsed.message : parsed.error;
const targetElementId = event.detail.target.id; const targetElementId = event.detail.target.id;
onclick = event.detail.successful ? null : "window.location.reload()"; if (targetElementId != "question-count") {
appendAlert(targetElementId, msgType, alertType, onclick); if (document.getElementById(targetElementId) && targetElementId.includes("question-")) {
document.getElementById('question-form').reset(); document.getElementById(targetElementId).outerHTML = '';
} }
}) Toastify({
text: msgType,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-${alertType} shadow alert-dismissible`,
close: true
}).showToast();
console.log(event.detail.requestConfig.elt);
if (event.detail.requestConfig.elt.id == 'question-form') {
document.getElementById('question-form').reset();
document.getElementById('charCount').textContent = "{{ cfg.charLimit }}";
}
}
}
});
</script> </script>
{% endblock %} {% endblock %}

BIN
wakatime-cli.zip Normal file

Binary file not shown.