1.2.0-alpha

This commit is contained in:
mst 2024-09-08 15:46:55 +03:00
parent e0925a3d1c
commit 30d1206ebc
24 changed files with 161 additions and 80 deletions

45
app.py
View file

@ -99,8 +99,15 @@ def render_markdown(text):
def index():
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (True,))
questions = cursor.fetchall()
cursor.execute("SELECT * FROM questions WHERE answered=%s AND pinned=%s ORDER BY creation_date DESC", (True, True))
pinned_questions = cursor.fetchall()
cursor.execute("SELECT * FROM questions WHERE answered=%s AND pinned=%s ORDER BY creation_date DESC", (True, False))
non_pinned_questions = cursor.fetchall()
questions = pinned_questions + non_pinned_questions
cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC")
answers = cursor.fetchall()
@ -123,7 +130,7 @@ def index():
def inbox():
conn = func.connectToDb()
cursor = conn.cursor(dictionary=True)
cursor.execute("SELECT * FROM questions WHERE answered=%s", (False,))
cursor.execute("SELECT * FROM questions WHERE answered=%s ORDER BY creation_date DESC", (False,))
questions = cursor.fetchall()
cursor.close()
@ -146,13 +153,13 @@ def login():
admin_password = request.form.get('admin_password')
if admin_password == os.environ.get('ADMIN_PASSWORD'):
session['logged_in'] = True
return redirect(url_for('admin.index'))
return redirect(url_for('index'))
else:
flash("Wrong password", 'danger')
return redirect(url_for('admin.login'))
else:
if logged_in:
return redirect('admin.index')
return redirect('index')
else:
return render_template('admin/login.html')
@ -276,6 +283,34 @@ def returnToInbox():
return {'message': 'Successfully returned question to inbox.'}, 200
@api_bp.route('/pin_question/', methods=['POST'])
def pinQuestion():
question_id = request.args.get('question_id', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor()
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', '')
if not question_id:
abort(400, "Missing 'question_id' attribute or 'question_id' is empty")
conn = func.connectToDb()
cursor = conn.cursor()
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'])
@loginRequired
def addAnswer():

View file

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

View file

@ -110,9 +110,12 @@ def renderMarkdown(text):
'i',
'br',
's',
'del'
'del',
'a'
]
allowed_attrs = {}
allowed_attrs = {
'a': 'href'
}
# hard_wrap=True means that newlines will be
# converted into <br> tags
#
@ -141,8 +144,8 @@ def generateMetadata(question=None, answer=None):
# if question is specified, generate metadata for that question
if question and answer:
metadata.update({
'title': trimContent(f"{question['content']}", 150) + " | " + cfg['instance']['title'],
'description': trimContent(f"{answer['content']}", 150),
'title': trimContent(question['content'], 150) + " | " + cfg['instance']['title'],
'description': trimContent(answer['content'], 150),
'url': cfg['instance']['fullBaseUrl'] + url_for('viewQuestion', question_id=question['id']),
'image': cfg['instance']['image']
})

View file

@ -11,7 +11,8 @@ CREATE TABLE IF NOT EXISTS questions (
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL,
answered BOOLEAN NOT NULL DEFAULT FALSE,
answer_id INT
answer_id INT,
pinned BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB;
ALTER TABLE questions

View file

@ -25,7 +25,7 @@
--bs-primary-rgb: 127,98,240;
--bs-primary-subtle: color-mix(in srgb, var(--bs-primary) 10%, transparent);
--bs-danger-bg-subtle: #2c0b0e;
--bs-link-color: color-mix(in srgb, var(--bs-primary) 70%, white);
--bs-link-color: color-mix(in srgb, var(--bs-primary) 60%, white);
}
.btn {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 B

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -8,6 +8,9 @@
id="svg9"
sodipodi:docname="catask.svg"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
inkscape:export-filename="catask-16.png"
inkscape:export-xdpi="4.0421052"
inkscape:export-ydpi="4.0421052"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
@ -22,8 +25,8 @@
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.48026316"
inkscape:cx="346.68493"
inkscape:cy="368.54794"
inkscape:cx="316.49315"
inkscape:cy="533.04109"
inkscape:window-width="1280"
inkscape:window-height="962"
inkscape:window-x="0"
@ -42,7 +45,7 @@
d="M372.86 340.836C361.458 364.034 337.594 380 310 380H69.9999C42.4057 380 18.5407 364.033 7.13956 340.835C0.0540733 303.832 2.43122 265.23 21.5492 228.243C22.3857 226.625 24.2343 225.797 25.9907 226.281C61.5026 236.067 97.5669 264.887 131.247 340.717C131.983 342.374 133.752 343.367 135.546 343.105C160.424 339.463 177.96 339.001 190 339.001C202.04 339.001 219.576 339.463 244.454 343.105C246.248 343.367 248.017 342.375 248.753 340.717C282.433 264.887 318.497 236.067 354.009 226.281C355.765 225.797 357.614 226.625 358.45 228.243C377.568 265.23 379.945 303.833 372.86 340.836Z"
fill="#DA75FF"
id="path1"
style="fill:#e59bff;fill-opacity:1" />
style="fill:#caacff;fill-opacity:1" />
<mask
id="path-3-inside-1_603_9"
fill="white">
@ -77,12 +80,14 @@
gradientUnits="userSpaceOnUse">
<stop
stop-color="#CE95FF"
id="stop4" />
id="stop4"
offset="0"
style="stop-color:#9a69ce;stop-opacity:1;" />
<stop
offset="1"
stop-color="#8B52BC"
id="stop5"
style="stop-color:#6a3a93;stop-opacity:1;" />
style="stop-color:#6f42c1;stop-opacity:1;" />
</linearGradient>
<linearGradient
id="paint1_linear_603_9"
@ -95,13 +100,13 @@
<stop
stop-color="#CE5AFF"
id="stop6"
offset="0"
offset="0.2899459"
style="stop-color:#ffffff;stop-opacity:1;" />
<stop
offset="1"
stop-color="#C12FFF"
id="stop7"
style="stop-color:#d973ff;stop-opacity:1;" />
style="stop-color:#caacff;stop-opacity:1;" />
</linearGradient>
<linearGradient
id="paint2_linear_603_9"

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 B

After

Width:  |  Height:  |  Size: 710 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -42,7 +42,7 @@
<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-3">
<div class="form-check mb-2">
<input
class="form-check-input"
type="checkbox"
@ -53,6 +53,17 @@
<input type="hidden" id="allowAnonQuestions" name="allowAnonQuestions" value="{{ cfg.allowAnonQuestions }}">
<label for="_allowAnonQuestions" class="form-check-label">Allow anonymous questions</label>
</div>
<div class="form-check mb-3">
<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">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>

View file

@ -41,21 +41,21 @@
<body class="ms-2 me-2 mb-2">
<a class="visually-hidden-focusable" href="#main-content">Skip to content</a>
<div class="container-fluid">
{% if logged_in %}
<div class="d-flex justify-content-between align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<div class="d-flex {% if logged_in %}justify-content-between {% endif %}align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<ul class="nav nav-underline position-relative {% if not logged_in %}mb-3{% endif %}">
<li class="nav-item d-flex align-items-center"><a href="{{ url_for('index') }}"><img src="{{ url_for('static', filename='icons/catask.svg') }}" width="32" height="32"></a></li>
<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 class="nav-item"><a class="nav-link {{ adminLink }}" id="admin-link" href="{{ url_for('admin.index') }}">Admin</a></li>
{% endif %}
</ul>
{% if logged_in %}
<ul class="nav nav-underline m-0">
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul>
{% endif %}
</div>
{% else %}
<div class="mt-3 mb-3" aria-hidden="true"></div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}

View file

@ -3,49 +3,60 @@
{% set inboxLink = 'active' %}
{% block content %}
{% if questions != [] %}
{% for question in questions %}
<div class="card mb-3 mt-3 alert-placeholder" 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">{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
<h3 class="fs-4">{{ len(questions) }} <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-header">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title mt-1 mb-1">
{% if question.from_who %}
{{ question.from_who }}
{% else %}
<i class="bi bi-incognito" data-bs-toggle="tooltip" data-bs-title="This question was asked anonymously" data-bs-placement="top"></i> {{ cfg.anonName }}
{% endif %}
</h5>
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div>
<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">
<div class="form-group d-sm-grid d-md-block gap-2">
<textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
<div class="d-flex flex-sm-column flex-md-row-reverse gap-2">
<button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
</div>
</div>
</form>
</div>
</div>
<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">
<div class="form-group d-sm-grid d-md-block gap-2">
<textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
<div class="d-flex flex-sm-column flex-md-row-reverse gap-2">
<button type="submit" class="btn btn-primary">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Answer
</button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
<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">
<div class="modal-header">
<h1 class="modal-title fs-5" id="q-{{ question.id }}-modal-label">Confirmation</h1>
<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>
</div>
<div class="modal-footer">
<button type="button" class="btn" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Confirm</button>
</div>
</div>
</div>
</form>
</div>
</div>
<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">
<div class="modal-header">
<h1 class="modal-title fs-5" id="q-{{ question.id }}-modal-label">Confirmation</h1>
<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>
</div>
<div class="modal-footer">
<button type="button" class="btn" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" data-bs-dismiss="modal" hx-delete="{{ url_for('api.deleteQuestion', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">Confirm</button>
</div>
</div>
</div>
{% endfor %}
</div>
{% endfor %}
{% else %}
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
{% endif %}

View file

@ -40,6 +40,10 @@
</div>
{% if combined %}
<div class="col-sm-8">
{% if cfg.showQuestionCount == true %}
<h3 class="fs-4">{{ len(combined) }} <span class="fw-light">question(s)</span></h3>
{% endif %}
<div id="top-response-container"></div>
{% for item in combined %}
<div class="card mt-3 mb-3" id="question-{{ item.question.id }}">
<div class="card-header">
@ -51,28 +55,39 @@
<i class="bi bi-incognito" data-bs-toggle="tooltip" data-bs-title="This question was asked anonymously" data-bs-placement="top"></i> {{ cfg.anonName }}
{% endif %}
</h5>
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ item.question.creation_date }}" data-bs-placement="top">{{ formatRelativeTime(str(item.question.creation_date)) }}</h6>
<h6 class="card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ item.question.creation_date.strftime("%B %d, %Y %H:%M") }}" data-bs-placement="top">{{ formatRelativeTime(str(item.question.creation_date)) }}</h6>
</div>
<div class="card-text markdown-content">{{ item.question.content | render_markdown }}</div>
</div>
<div class="card-body">
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="text-decoration-none text-reset">
{% for answer in item.answers %}
<div class="markdown-content">{{ answer.content }}</div>
</div>
</a>
{% for answer in item.answers %}
<div class="markdown-content">{{ answer.content | render_markdown }}</div>
</div>
<div class="card-footer pt-0 pb-0 ps-3 pe-2 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div>
<span class="fs-6" data-bs-toggle="tooltip" data-bs-title="{{ answer.creation_date.strftime("%B %d, %Y %H:%M") }}">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>
{% if item.question.pinned %}
<span class="ms-1"><i class="bi bi-pin"></i> <span class="fw-medium">Pinned</span></span>
{% endif %}
</div>
{% endfor %}
<div class="dropdown">
<button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})">Copy link</button></li>
{% if logged_in %}
<li><button class="bg-hover-danger text-danger dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=item.question.id) }}" hx-target="#question-{{ item.question.id }}" hx-swap="none">Return to inbox</button></li>
{% endif %}
</ul>
</div>
<div class="d-flex align-items-center">
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="btn pt-2 pb-2 text-body-secondary" data-bs-toggle="tooltip" data-bs-title="View question"><i class="bi bi-box-arrow-up-right"></i></a>
<div class="dropdown">
<button class="btn btn-sm pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i style="font-size: 1.2rem;" class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})">Copy link</button></li>
{% if logged_in %}
{% if not item.question.pinned %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.pinQuestion', question_id=item.question.id) }}" hx-target="#top-response-container" hx-swap="none">Pin</button></li>
{% else %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.unpinQuestion', question_id=item.question.id) }}" hx-target="#top-response-container" hx-swap="none">Unpin</button></li>
{% endif %}
<li><button class="bg-hover-danger text-danger dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=item.question.id) }}" hx-target="#question-{{ item.question.id }}" hx-swap="none">Return to inbox</button></li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% endfor %}

View file

@ -2,7 +2,7 @@
{% block title %}"{{ trimContent(question.content, 15) }}" - "{{ trimContent(answer.content, 15) }}"{% endblock %}
{% block content %}
<div class="row">
<div class="col-sm-6 m-auto">
<div class="col-sm-8 m-auto">
<div class="card mt-2 mb-2" id="question-{{ question.id }}">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
@ -12,7 +12,7 @@
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<p class="mb-0">{{ answer.content }}</p>
<div class="markdown-content">{{ answer.content | render_markdown }}</div>
</div>
<div class="card-footer pt-0 pb-0 ps-3 pe-2 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6">answered {{ formatRelativeTime(str(answer.creation_date)) }}</span>