mirror of
https://codeberg.org/catask-org/catask.git
synced 2025-04-19 13:23:41 -05:00
1.4.0-alpha
This commit is contained in:
parent
d4c2c7df88
commit
04662eaddc
19 changed files with 535 additions and 152 deletions
|
@ -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
22
CHANGELOG.md
Normal 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)
|
21
README.md
21
README.md
|
@ -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
6
UPDATE.md
Normal 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
52
app.py
|
@ -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
|
||||
|
|
|
@ -9,5 +9,6 @@
|
|||
"anonName": "Anonymous",
|
||||
"lockInbox": false,
|
||||
"allowAnonQuestions": true,
|
||||
"showQuestionCount": false
|
||||
"showQuestionCount": false,
|
||||
"noDeleteConfirm": false
|
||||
}
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -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
|
||||
|
|
12
roadmap.md
12
roadmap.md
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
91
static/css/toastify.css
Normal 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
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
15
static/js/toastify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
@ -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 %}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
BIN
wakatime-cli.zip
Normal file
Binary file not shown.
Loading…
Add table
Reference in a new issue