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

@ -1,6 +1,7 @@
DB_HOST = 127.0.0.1
DB_NAME = catask
DB_USER =
DB_PASS =
ADMIN_PASSWORD =
APP_SECRET =
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

@ -6,25 +6,25 @@ a work-in-progress minimal single-user q&a software
> catask is alpha software, therefore bugs are expected to happen
## Prerequisites
- MySQL/MariaDB
- Python 3.10+ (3.12+ recommended)
- MySQL/MariaDB
- Python 3.10+ (3.12+ recommended)
## Install
Clone this repository: `git clone https://git.gay/mst/catask.git`
Clone this repository: `git clone https://git.gay/mst/catask.git`
### VPS-specific
Go into the cloned repository, create a virtual environment and activate it:
Go into the cloned repository, create a virtual environment and activate it:
#### Linux
```python -m venv venv && . venv/bin/activate```
#### Windows (PowerShell)
```python -m venv venv; .\venv\Scripts\activate```
```python -m venv venv; .\venv\Scripts\activate```
---
After that, install required packages:
```pip install -r requirements.txt```
```pip install -r requirements.txt```
### Shared hosting-specific
If your shared hosting provider supports [WSGI](https://w.wiki/_vTN2), [FastCGI](https://w.wiki/9EeQ), or something similar, use it (technically any CGI protocol could work)
@ -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())'`
@ -49,15 +50,19 @@ Configure in Admin panel after installing (located at `https://yourdomain.tld/ad
---
After you're done configuring CatAsk, init the database: `flask init-db`
If that doesn't work (e.g. tables are missing), try importing schema.sql into the created database manually
If that doesn't work (e.g. tables are missing), try importing schema.sql into the created database manually
## Usage
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
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;```

52
app.py
View file

@ -39,7 +39,7 @@ def initDatabase():
print("Connected successfully")
conn.database = dbName
except mysql.connector.Error as error:
if error.errno == errorcode.ER_ACCESS_DENIED_ERROR:
print("Bad credentials")
@ -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()
@ -56,7 +57,7 @@ def initDatabase():
else:
print("Error:", error)
return
with open('schema.sql', 'r') as schema_file:
schema = schema_file.read()
try:
@ -124,7 +125,7 @@ def index():
'question': question,
'answers': question_answers
})
cursor.close()
conn.close()
return render_template('index.html', combined=combined, urllib=urllib, trimContent=func.trimContent, metadata=metadata, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime)
@ -136,7 +137,7 @@ def inbox():
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (False,))
questions = cursor.fetchall()
cursor.close()
conn.close()
return render_template('inbox.html', questions=questions, formatRelativeTime=func.formatRelativeTime)
@ -156,7 +157,7 @@ def seeAskedQuestions():
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE asker_id=%s ORDER BY creation_date DESC", (asker_id,))
questions = cursor.fetchall()
cursor.close()
conn.close()
return render_template('asked_questions.html', questions=questions, formatRelativeTime=func.formatRelativeTime)
@ -235,9 +236,9 @@ 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:
abort(400, "Question field must not be empty")
if not antispam:
@ -260,13 +261,13 @@ def addQuestion():
cursor.execute("INSERT INTO questions (from_who, content, answered) VALUES (%s, %s, %s)", (from_who, question, False,))
cursor.close()
conn.close()
return {'message': 'Question asked successfully!'}, 201
@api_bp.route('/delete_question/', methods=['DELETE'])
@loginRequired
def deleteQuestion():
question_id = request.args.get('question_id', '')
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
@ -275,7 +276,7 @@ def deleteQuestion():
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
cursor.close()
conn.close()
return {'message': 'Successfully deleted question.'}, 200
@api_bp.route('/return_to_inbox/', methods=['POST'])
@ -295,17 +296,17 @@ def returnToInbox():
'content': row[1],
'creation_date': row[2]
}
cursor.execute("DELETE FROM questions WHERE id=%s", (question_id,))
cursor.execute("INSERT INTO questions (from_who, content, creation_date, answered) VALUES (%s, %s, %s, %s)", (question["from_who"], question["content"], question["creation_date"], False,))
cursor.close()
conn.close()
return {'message': 'Successfully returned question to inbox.'}, 200
@api_bp.route('/pin_question/', methods=['POST'])
def pinQuestion():
question_id = request.args.get('question_id', '')
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
@ -314,12 +315,12 @@ def pinQuestion():
cursor.execute("UPDATE questions SET pinned=%s WHERE id=%s", (True, question_id))
cursor.close()
conn.close()
return {'message': 'Successfully pinned question.'}, 200
@api_bp.route('/unpin_question/', methods=['POST'])
def unpinQuestion():
question_id = request.args.get('question_id', '')
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
@ -328,7 +329,7 @@ def unpinQuestion():
cursor.execute("UPDATE questions SET pinned=%s WHERE id=%s", (False, question_id))
cursor.close()
conn.close()
return {'message': 'Successfully unpinned question.'}, 200
@api_bp.route('/add_answer/', methods=['POST'])
@ -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():
@ -420,7 +438,7 @@ def updateConfig():
for nested_key in nested_keys[:-1]:
current_dict = current_dict.setdefault(nested_key, {})
# Convert the checkbox value 'True'/'False' strings to actual booleans
if value.lower() == 'true':
value = True

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,10 +1,12 @@
# 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
* [ ] bulk deleting questions from inbox
* [ ] blocking askers by ip
* [x] make an admin page
* [x] implement an optional blacklist of words
* [x] deleting answered questions OR returning them to inbox like retrospring does
* [ ] bulk deleting questions from inbox
* [ ] blocking askers by ip
* [x] make an admin page
* [x] implement an optional blacklist of words
* [ ] add more customization options (theme + favicon)

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,41 +56,63 @@
<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"
type="checkbox"
name="_lockInbox"
id="_lockInbox"
value="{{ cfg.lockInbox }}"
<input
class="form-check-input"
type="checkbox"
name="_lockInbox"
id="_lockInbox"
value="{{ cfg.lockInbox }}"
{% if cfg.lockInbox == true %}checked{% endif %}>
<input type="hidden" id="lockInbox" name="lockInbox" value="{{ cfg.lockInbox }}">
<label for="_lockInbox" class="form-check-label">Lock inbox and don't allow new questions</label>
</div>
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
name="_allowAnonQuestions"
id="_allowAnonQuestions"
value="{{ cfg.allowAnonQuestions }}"
<input
class="form-check-input"
type="checkbox"
name="_allowAnonQuestions"
id="_allowAnonQuestions"
value="{{ cfg.allowAnonQuestions }}"
{% if cfg.allowAnonQuestions == true %}checked{% endif %}>
<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"
type="checkbox"
name="_showQuestionCount"
id="_showQuestionCount"
value="{{ cfg.showQuestionCount }}"
<input
class="form-check-input"
type="checkbox"
name="_showQuestionCount"
id="_showQuestionCount"
value="{{ cfg.showQuestionCount }}"
{% if cfg.showQuestionCount == true %}checked{% endif %}>
<input type="hidden" id="showQuestionCount" name="showQuestionCount" value="{{ cfg.showQuestionCount }}">
<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,8 +22,8 @@
</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>
{% endif %}
@ -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">
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
{% autoescape off %}
<h2 class="h5 text-center fw-light">{{ cfg.instance.description }}</h2>
{% endautoescape %}
<div>
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
{% autoescape off %}
<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,18 +52,18 @@
<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
<input
class="form-check-input"
type="checkbox"
name="_private"
reserved for version 1.5.0 or later
<input
class="form-check-input"
type="checkbox"
name="_private"
id="_private">
<input type="hidden" id="private" name="private">
<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,15 +217,54 @@
</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}`;
window.open(shareUrl, '_blank');
}
@ -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.