diff --git a/.gitignore b/.gitignore index 5500a2b..10aa1f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ venv/ __pycache__/ .env +config.py +word_blacklist.txt diff --git a/app.py b/app.py index f2d7bd7..057df75 100644 --- a/app.py +++ b/app.py @@ -14,12 +14,12 @@ logged_in = False load_dotenv() -app = Flask(cfg.appName) +app = Flask(const.appName) app.secret_key = os.environ.get("APP_SECRET") # -- blueprints -- -api_bp = Blueprint('api', cfg.appName) -admin_bp = Blueprint('admin', cfg.appName) +api_bp = Blueprint('api', const.appName) +admin_bp = Blueprint('admin', const.appName) # -- cli commands -- @@ -83,7 +83,12 @@ def before_request(): @app.context_processor def inject_stuff(): - return dict(cfg=cfg, logged_in=logged_in, version=const.version, appName=cfg.appName) + return dict(const=const, cfg=cfg, logged_in=logged_in, version_id=const.version_id, version=const.version, appName=const.appName) + +# -- template filters -- +@app.template_filter('render_markdown') +def render_markdown(text): + return func.renderMarkdown(text) # -- client (frontend) routes -- @@ -124,7 +129,7 @@ def inbox(): def viewQuestion(question_id): question = func.getQuestion(question_id) answer = func.getAnswer(question_id) - return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str) + return render_template('view_question.html', question=question, answer=answer, formatRelativeTime=func.formatRelativeTime, str=str, trimContent=func.trimContent) # -- admin client routes -- @@ -160,8 +165,7 @@ def index(): if action == 'update_word_blacklist': with open(const.blacklistFile, 'w') as file: file.write(blacklist) - flash("Changes saved!", 'success') - return redirect(url_for('admin.index')) + return {'message': 'Changes saved!'}, 200 else: blacklist = func.readPlainFile(const.blacklistFile) return render_template('admin/index.html', blacklist=blacklist) @@ -230,16 +234,17 @@ def returnToInbox(): conn = func.connectToDb() cursor = conn.cursor() - cursor.execute("SELECT from_who, content FROM questions WHERE id=%s", (question_id,)) + cursor.execute("SELECT from_who, content, creation_date FROM questions WHERE id=%s", (question_id,)) row = cursor.fetchone() question = { 'from_who': row[0], - 'content': row[1] + '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, answered) VALUES (%s, %s, %s)", (question["from_who"], question["content"], 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() conn.close() diff --git a/config.example.py b/config.example.py new file mode 100644 index 0000000..9f1fc2d --- /dev/null +++ b/config.example.py @@ -0,0 +1,4 @@ +instanceTitle = "CatAsk" +instanceDesc = 'Ask me something!' +instanceImage = '/static/img/ca_screenshot.png' +fullBaseUrl = 'https://' diff --git a/config.py b/config.py index 53be037..e126966 100644 --- a/config.py +++ b/config.py @@ -1,2 +1,4 @@ -appName = 'CatAsk' +instanceTitle = "Nekomimi's question box" +instanceDesc = 'Ask me things!' +instanceImage = '/static/img/ca_screenshot.png' fullBaseUrl = 'http://192.168.92.146:5000' diff --git a/constants.py b/constants.py index 269c8a5..41692fd 100644 --- a/constants.py +++ b/constants.py @@ -1,3 +1,6 @@ antiSpamFile = 'wordlist.txt' blacklistFile = 'word_blacklist.txt' -version = '0.1.1' +appName = 'CatAsk' +version = '1.0.0' +# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9 +version_id = '-alpha' diff --git a/functions.py b/functions.py index 7c11613..da5c482 100644 --- a/functions.py +++ b/functions.py @@ -1,4 +1,7 @@ +from markupsafe import Markup +from bleach.sanitizer import Cleaner from datetime import datetime +import mistune import humanize import mysql.connector import config as cfg @@ -34,7 +37,7 @@ def connectToDb(): user=dbUser, password=dbPass, database=dbName, - pool_name=f"{cfg.appName}-pool", + pool_name=f"{const.appName}-pool", pool_size=32, autocommit=True ) @@ -71,3 +74,46 @@ def readPlainFile(file, split=False): def getRandomWord(): items = readPlainFile(const.antiSpamFile, split=True) return random.choice(items) + +def trimContent(var, trim): + return var[:trim] + '...' if len(var) > trim else var + +def renderMarkdown(text): + plugins = [ + 'strikethrough' + ] + allowed_tags = [ + 'p', + 'em', + 'b', + 'strong', + 'i', + 'br', + 's', + 'del' + ] + allowed_attrs = {} + # hard_wrap=True means that newlines will be + # converted into
tags + # + # yes, markdown usually lets you make line breaks only + # with 2 spaces or
tag, but hard_wrap is enabled to keep + # sanity of whoever will use this software + # (after all, not everyone knows markdown syntax) + md = mistune.create_markdown( + escape=True, + plugins=plugins, + hard_wrap=True + ) + html = md(text) + cleaner = Cleaner(tags=allowed_tags, attributes=allowed_attrs) + clean_html = cleaner.clean(html) + return Markup(clean_html) + +def generateMetadata(question=None): + metadata = { + 'title': cfg.instanceTitle, + 'description': cfg.instanceDesc, + 'url': cfg.fullBaseUrl, + 'image': cfg.instanceImage + } diff --git a/requirements.txt b/requirements.txt index bf78b03..a0f97be 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ flask python-dotenv mysql-connector-python==8.2.0 -humanize \ No newline at end of file +humanize +mistune +bleach diff --git a/static/css/style.css b/static/css/style.css index 58178c0..6218fa4 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -5,44 +5,123 @@ src: url("../fonts/rubik.woff2") format('woff2-variations'); } -:root, [data-bs-theme=light] { - --bs-primary: #6345d9; - --bs-primary-rgb: 214,107,26; +:root { + --bs-font-sans-serif: "Rubik", sans-serif; --bs-link-color-rgb: var(--bs-primary-rgb); --bs-nav-link-color: var(--bs-primary); +} + +[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-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white); - --bs-font-sans-serif: "Rubik", sans-serif; - --bs-dropdown-link-hover-bg: var(--bs-primary); + --bs-danger: #dc3545; + --bs-danger-bg-subtle: #f8e6e8; +} + +[data-bs-theme=dark] { + --bs-primary: #7f62f0; + --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); } .btn { --bs-btn-border-radius: .5rem; } -.btn-primary { +[data-bs-theme=light] .btn-primary { --bs-btn-bg: color-mix(in srgb, var(--bs-primary) 90%, white); --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); - font-weight: 500; } -.nav-link { +[data-bs-theme=dark] .btn-primary { + --bs-btn-bg: color-mix(in srgb, var(--bs-primary) 80%, 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); +} + +.btn.btn-primary { + font-weight: 500; + border: none; + background-image: var(--bs-gradient); + padding: 7px 13px; +} + +[data-bs-theme=light] .nav-link { color: color-mix(in srgb, var(--bs-primary) 90%, black); } +[data-bs-theme=dark] .nav-link, [data-bs-theme=dark] a { + color: var(--bs-link-color); +} + +a:hover { + color: var(--bs-link-hover-color); +} + +[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-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-color: color-mix(in srgb, var(--bs-primary) 70%, white); +} + .dropdown-item.active, .dropdown-item:active { background-color: var(--bs-primary); } +.dropdown-menu { + padding: .5em; +} +.dropdown-item { + border-radius: var(--bs-border-radius); + padding-left: .6em; + padding-right: .6em; +} + +.bg-hover-danger.dropdown-item:hover { + --bs-dropdown-link-hover-bg: var(--bs-danger-bg-subtle); + --bs-dropdown-link-hover-color: var(--bs-danger); +} + +.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active { + --bs-dropdown-link-active-bg: var(--bs-danger); + background-color: red !important; + color: white !important; + --bs-dropdown-link-active-color: white; +} + .form-control:focus { 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); } -.icon-link.dropdown-toggle::after { +.no-arrow.dropdown-toggle::after { border: none; display: none; } + +.markdown-content p { + margin: 0; +} + +.htmx-indicator { + display: none; +} +.htmx-request .htmx-indicator, +.htmx-request.htmx-indicator { + display: inline-block; +} diff --git a/static/img/ca_screenshot.png b/static/img/ca_screenshot.png new file mode 100644 index 0000000..f7bd124 Binary files /dev/null and b/static/img/ca_screenshot.png differ diff --git a/static/js/color-modes.js b/static/js/color-modes.js new file mode 100644 index 0000000..ce00bd2 --- /dev/null +++ b/static/js/color-modes.js @@ -0,0 +1,86 @@ +/*! + * Color mode toggler for Bootstrap's docs (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors + * Licensed under the Creative Commons Attribution 3.0 Unported License. + */ + +(() => { + 'use strict' + + const getStoredTheme = () => localStorage.getItem('theme') + const setStoredTheme = theme => localStorage.setItem('theme', theme) + + const getPreferredTheme = () => { + const storedTheme = getStoredTheme() + if (storedTheme) { + return storedTheme + } + + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' + } + + const setTheme = theme => { + if (theme === 'auto') { + document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) + } else { + document.documentElement.setAttribute('data-bs-theme', theme) + } + } + + setTheme(getPreferredTheme()) + + const showActiveTheme = (theme, focus = false) => { + const themeSwitcher = document.querySelector('#bd-theme') + + if (!themeSwitcher) { + return + } + + const themeSwitcherText = document.querySelector('#bd-theme-text') + const activeThemeIcon = document.querySelector('#theme-icon-active') + const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`) + const themeCheck = btnToActive.querySelector(`.theme-check`) + const iconOfActiveBtn = btnToActive.querySelector('i').className + + document.querySelectorAll('[data-bs-theme-value]').forEach(element => { + element.classList.remove('active') + element.setAttribute('aria-pressed', 'false') + }) + + document.querySelectorAll('.theme-check').forEach(element => { + element.classList.add('d-none') + }) + + btnToActive.classList.add('active') + btnToActive.setAttribute('aria-pressed', 'true') + themeCheck.classList.remove('d-none') + activeThemeIcon.className = iconOfActiveBtn + const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})` + themeSwitcher.setAttribute('aria-label', themeSwitcherLabel) + + if (focus) { + themeSwitcher.focus() + } + } + + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const storedTheme = getStoredTheme() + if (storedTheme !== 'light' && storedTheme !== 'dark') { + setTheme(getPreferredTheme()) + } + }) + + window.addEventListener('DOMContentLoaded', () => { + showActiveTheme(getPreferredTheme()) + + document.querySelectorAll('[data-bs-theme-value]') + .forEach(toggle => { + toggle.addEventListener('click', () => { + const theme = toggle.getAttribute('data-bs-theme-value') + setStoredTheme(theme) + setTheme(theme) + showActiveTheme(theme, true) + }) + }) + }) +})() diff --git a/templates/admin/index.html b/templates/admin/index.html index f5dd1e6..3130d8e 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -2,17 +2,44 @@ {% block title %}Admin{% endblock %} {% set adminLink = 'active' %} {% block content %} -

Admin panel

-
-
-
+

Admin panel

+
+ -
- - +
+ +
- + -
-
+ +{% endblock %} +{% block scripts %} + {% endblock %} diff --git a/templates/base.html b/templates/base.html index 04441f6..8fd27ad 100644 --- a/templates/base.html +++ b/templates/base.html @@ -9,25 +9,24 @@ - {% block title %}{% endblock %} - {{ cfg.appName }} + + {% block title %}{% endblock %} - {{ const.appName }}
{% if logged_in %} -
- {% endif %} -
+ {% block scripts %}{% endblock %} -
diff --git a/templates/inbox.html b/templates/inbox.html index 7f0ed25..1e5ed49 100644 --- a/templates/inbox.html +++ b/templates/inbox.html @@ -4,22 +4,45 @@ {% block content %} {% if questions != [] %} {% for question in questions %} -
-
+
+
-
{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}
-
{{ formatRelativeTime(str(question.creation_date)) }}
+
{% if question.from_who %}{{ question.from_who }}{% else %}Anonymous{% endif %}
+
{{ formatRelativeTime(str(question.creation_date)) }}
-

{{ question.content }}

+
{{ question.content | render_markdown }}
+
+
- - + +
+ {% endfor %} {% else %}

Inbox is currently empty.

@@ -27,6 +50,8 @@ {% endblock %} {% block scripts %} {% endblock %}