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_USER =
DB_PASS =
DB_PORT =
ADMIN_PASSWORD =
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_USER` - database user
`DB_PASS` - database password
`DB_PORT` - database port (usually 3306)
`ADMIN_PASSWORD` - password to access admin panel
`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`
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/`
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
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"),
password=os.environ.get("DB_PASS"),
host=os.environ.get("DB_HOST"),
port=os.environ.get("DB_PORT"),
database='mysql'
)
cursor = conn.cursor()
@ -235,7 +236,7 @@ def addQuestion():
from_who = request.form.get('from_who', cfg['anonName'])
question = request.form.get('question', '')
antispam = request.form.get('antispam', '')
# reserved for version 1.4.0
# reserved for version 1.5.0 or later
# private = request.form.get('private')
if not question:
@ -358,6 +359,22 @@ def addAnswer():
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'])
def listQuestions():
answered = request.args.get('answered', False)
@ -378,6 +395,7 @@ def listAnswers():
cursor.close()
conn.close()
return jsonify(answers)
"""
@api_bp.route('/view_question/', methods=['GET'])
def viewQuestion():

View file

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

View file

@ -2,6 +2,6 @@ antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt'
configFile = 'config.json'
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
version_id = '-alpha'

View file

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

View file

@ -1,5 +1,7 @@
# CatAsk stable roadmap
* [ ] content warnings
* [x] move to toastify for alerts
* [x] make stuff more accessible
* [ ] implement private questions
* [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,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL
) ENGINE=InnoDB;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS questions (
id INT PRIMARY KEY AUTO_INCREMENT,
@ -13,9 +13,9 @@ CREATE TABLE IF NOT EXISTS questions (
answered BOOLEAN NOT NULL DEFAULT FALSE,
answer_id INT,
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
) ENGINE=InnoDB;
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
ALTER TABLE questions
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] {
--bs-primary: #6345d9;
--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-danger: #dc3545;
--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-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-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), black);
}
[data-bs-theme=dark] {
@ -32,7 +33,7 @@
--bs-body-bg-rgb: 32, 32, 32;
--bs-primary: #7f62f0;
--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-link-color: color-mix(in srgb, var(--bs-primary) 65%, 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-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-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), white);
}
[data-bs-theme=light] .light-invert,
@ -47,11 +49,21 @@
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 {
--bs-btn-border-radius: .5rem;
}
.btn:not(.btn-primary,.btn-danger,.btn-success):focus-visible {
outline: revert;
.btn.disabled, .btn:disabled, fieldset:disabled .btn {
background-color: var(--bs-btn-disabled-bg);
}
[data-bs-theme=light] .btn-primary {
@ -61,15 +73,17 @@
--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);
--bs-btn-disabled-bg: color-mix(in srgb, var(--bs-primary) 70%, white);
}
[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-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);
--bs-btn-disabled-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
}
.btn.btn-primary {
@ -79,17 +93,17 @@
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);
}
.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);
}
.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;
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;
}
.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 {
--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);
}
[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);
}
@ -154,9 +168,11 @@ a:hover {
--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);
border-color: color-mix(in srgb, var(--bs-primary), transparent);
outline: 0;
}
.list-group-item.active {
@ -176,6 +192,9 @@ a:hover {
.markdown-content p {
margin: 0;
}
.markdown-content ol {
margin-bottom: 0;
}
.htmx-indicator {
display: none;
@ -190,3 +209,24 @@ a:hover {
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' %}
{% block title %}Admin{% endblock %}
{% set adminLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %}
<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>
<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>
<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>
<input type="text" id="instance.title" name="instance.title" value="{{ cfg.instance.title }}" class="form-control">
<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 }}" oninput="updateText('instance.title', 'title')" class="form-control">
<p class="form-text">Title of this CatAsk instance</p>
</div>
<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>
<input type="text" id="instance.description" name="instance.description" value="{{ cfg.instance.description }}" class="form-control">
<label class="form-label d-flex flex-column flex-lg-row align-items-lg-center justify-content-between" for="instance.description">
<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>
</div>
<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">
<p class="form-text">Image that's going to be used in a link preview</p>
</div>
<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">
<p class="form-text">Full URL to homepage of this CatAsk instance without a trailing slash</p>
</div>
@ -37,6 +56,17 @@
<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>
</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">
<input
class="form-check-input"
@ -59,6 +89,17 @@
<input type="hidden" id="allowAnonQuestions" name="allowAnonQuestions" value="{{ cfg.allowAnonQuestions }}">
<label for="_allowAnonQuestions" class="form-check-label">Allow anonymous questions</label>
</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">
<input
class="form-check-input"
@ -71,7 +112,7 @@
<label for="_showQuestionCount" class="form-check-label">Show question count in homepage</label>
</div>
<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="visually-hidden" role="status">Loading...</span>
Save
@ -79,50 +120,92 @@
</div>
</form>
<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">
<div class="form-group mb-3">
<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>
<button type="submit" class="btn btn-primary mt-3">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<button type="submit" class="btn btn-primary mt-3" id="save-blacklist">
<span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</div>
</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 %}
{% 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>
// fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
marked.use({
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.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</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) {
console.log("reached event listener");
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);
const alertType = event.detail.successful ? 'success' : 'danger';
message = event.detail.successful ? parsed.message : parsed.error;
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>
{% endblock %}

View file

@ -36,7 +36,8 @@
<meta property="twitter:image" content="{{ metadata.image }}" />
<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>
<body class="ms-2 me-2 mb-2">
<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>
{% if logged_in %}
<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>
{% endif %}
</ul>

View file

@ -1,13 +1,16 @@
{% extends 'base.html' %}
{% block title %}Inbox {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %}
{% set inboxLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %}
{% 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">
{% for question in questions %}
<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="d-flex justify-content-between align-items-center">
<h5 class="card-title mt-1 mb-1">
@ -19,7 +22,7 @@
</h5>
<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 %}
<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>
<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">
<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>
<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="visually-hidden" role="status">Loading...</span>
Answer
</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>
{% 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>
</form>
</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-dialog modal-dialog-centered">
<div class="modal-content">
@ -55,7 +63,7 @@
<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>
<p class="mb-0">Are you sure you want to delete this question?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn" data-bs-dismiss="modal">Cancel</button>
@ -64,6 +72,7 @@
</div>
</div>
</div>
{% endif %}
</div>
{% endfor %}
</div>
@ -72,28 +81,34 @@
{% endif %}
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></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 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 alertType = event.detail.successful ? 'success' : 'danger';
msgType = event.detail.successful ? parsed.message : parsed.error;
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>

View file

@ -1,12 +1,33 @@
{% extends 'base.html' %}
{% block title %}Home{% endblock %}
{% set homeLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %}
<div class="mt-5 mb-sm-2 mb-md-5">
<div>
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
{% 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 %}
</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 class="row">
<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>
<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">
<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>
</div>
<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>
<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 class="form-group mb-2">
<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-check mb-0 w-100">
reserved for version 1.4.0
reserved for version 1.5.0 or later
<input
class="form-check-input"
type="checkbox"
@ -40,8 +62,8 @@
<label for="_private" class="form-check-label">Ask privately</label>
</div>
#}
<button type="submit" class="btn btn-primary col-sm-4">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<button type="submit" class="btn btn-primary col-sm-4" id="ask-btn">
<span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Ask
</button>
@ -184,6 +206,7 @@
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
@ -194,11 +217,50 @@
</script>
<script>
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) {
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) {
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
@ -213,30 +275,42 @@ function shareOnFediverse(questionId, contentToShare) {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
const appendAlert = (elementId, message, type, onclick) => {
const alertPlaceholder = document.querySelector(`#${elementId}`);
alertPlaceholder.innerHTML = '';
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:beforeRequest', function(event) {
if (event.detail.target.id != "question-count") {
document.getElementById('ask-btn').setAttribute('disabled', true);
}
});
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;
if (jsonResponse) {
const parsed = JSON.parse(jsonResponse);
const alertType = event.detail.successful ? 'success' : 'danger';
msgType = event.detail.successful ? parsed.message : parsed.error;
const targetElementId = event.detail.target.id;
onclick = event.detail.successful ? null : "window.location.reload()";
appendAlert(targetElementId, msgType, alertType, onclick);
document.getElementById('question-form').reset();
if (targetElementId != "question-count") {
if (document.getElementById(targetElementId) && targetElementId.includes("question-")) {
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>
{% endblock %}

BIN
wakatime-cli.zip Normal file

Binary file not shown.