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_HOST = 127.0.0.1
DB_NAME = catask DB_NAME = catask
DB_USER = DB_USER =
DB_PASS = DB_PASS =
ADMIN_PASSWORD = DB_PORT =
APP_SECRET = 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 > catask is alpha software, therefore bugs are expected to happen
## Prerequisites ## Prerequisites
- MySQL/MariaDB - MySQL/MariaDB
- Python 3.10+ (3.12+ recommended) - Python 3.10+ (3.12+ recommended)
## Install ## 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 ### 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 #### Linux
```python -m venv venv && . venv/bin/activate``` ```python -m venv venv && . venv/bin/activate```
#### Windows (PowerShell) #### Windows (PowerShell)
```python -m venv venv; .\venv\Scripts\activate``` ```python -m venv venv; .\venv\Scripts\activate```
--- ---
After that, install required packages: After that, install required packages:
```pip install -r requirements.txt``` ```pip install -r requirements.txt```
### Shared hosting-specific ### 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) 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_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())'`
@ -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` 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 ## 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` 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;```

52
app.py
View file

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

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

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