Compare commits

...
Sign in to create a new pull request.

177 commits
dev ... main

Author SHA1 Message Date
mystieneko
adbfe71716 Merge pull request 'Add updating instructions for Docker' (#4) from theycallhermax/catask:main into main
Reviewed-on: https://codeberg.org/catask-org/catask/pulls/4
2025-03-05 20:08:20 +00:00
theycallhermax
0c17e5aee9 add docker updating instructions 2025-03-05 19:07:53 +00:00
mystie
504c25353c fix readme 2025-03-02 08:45:40 +00:00
mystie
c57385f6b0 update readme 2025-03-02 08:42:42 +00:00
mystieneko
94afd8b4f3 Merge pull request 'Add Docker Compose file' (#3) from theycallhermax/catask:main into main
Reviewed-on: https://codeberg.org/catask-org/catask/pulls/3
2025-03-02 08:41:34 +00:00
theycallhermax
10369dd23c
add favicons folder to catask-data volume 2025-03-02 00:45:10 -05:00
theycallhermax
db1938f95b
fix formatting in docker compose setup file 2025-03-01 23:38:10 -05:00
theycallhermax
21d401f253
uncomment docker section in readme 2025-03-01 23:38:10 -05:00
theycallhermax
252e9879ef
add docker instructions
cleanup docker compose
2025-03-01 23:38:10 -05:00
theycallhermax
dc6b4f7712
revert config.example.json changes 2025-03-01 23:38:10 -05:00
theycallhermax
e888f43ce7
port docker compose to postgres 2025-03-01 23:38:09 -05:00
max
d43b844456
set mariadb root host to %
Co-authored-by: mst <oivan2401@gmail.com>
2025-03-01 23:38:09 -05:00
max
8568df27d4
remove remote access fix 2025-03-01 23:38:09 -05:00
max
a0ab76a1d7
remove remote access fix in docker compose 2025-03-01 23:38:09 -05:00
max
539625fead
add bridge driver to catask network 2025-03-01 23:38:09 -05:00
max
e783ef3380
remove ipam config 2025-03-01 23:38:08 -05:00
max
70e867c978
remove bridge driver in ipam con fig 2025-03-01 23:38:08 -05:00
max
245f0df6eb
configure ipam in networks 2025-03-01 23:38:08 -05:00
max
2df2c2f6c1
apply 127.0.0.1 as catask service's ipv4 address 2025-03-01 23:38:08 -05:00
max
482b7a9397
remove port expose declaration in docker compose as the mariadb image already has this
add static ip address to catask network
2025-03-01 23:38:08 -05:00
max
3105512277
add mariadb fix config to docker-compose 2025-03-01 23:38:07 -05:00
max
7cd936a99e
add remote access fix config for mariadb 2025-03-01 23:38:07 -05:00
max
0c4d9ff6c0
expose mariadb's port 3306 as tcp 2025-03-01 23:38:07 -05:00
max
66e1354dd7
readd networks (again) 2025-03-01 23:38:07 -05:00
max
7727c6eeb2
use short syntax for catask config binds in docker compose 2025-03-01 23:38:07 -05:00
max
c14acdabe8
revert 2d35928e4a3bbb06294e0c659f6fa851b7aa675a
revert readd networks
2025-03-01 23:38:06 -05:00
max
8a37ff2718
readd networks 2025-03-01 23:38:06 -05:00
max
803c97fc61
add healthcheck to mariadb container
wait until mariadb is fully started when starting catask container
remove custom networks in docker compose
2025-03-01 23:38:06 -05:00
max
0a3e4760bc
add custom network for custom host binding 2025-03-01 23:38:06 -05:00
max
a559081639
remove admin password argument in dockerfile 2025-03-01 23:38:06 -05:00
max
7cf9132b31
remove admin password argument in docker compose 2025-03-01 23:38:05 -05:00
max
f099958fb1
bind to 0.0.0.0:8000 in dockerfile entrypoint so that docker port exposing works properly 2025-03-01 23:38:05 -05:00
max
49b4530a2b
connect schema to mariadb container's initdb 2025-03-01 23:38:05 -05:00
max
1fff70f06c
remove .env and config.json file creation in dockerfile 2025-03-01 23:38:05 -05:00
max
0117e2da62
bind to proper files in docker compose 2025-03-01 23:38:05 -05:00
max
60502eca70
revert b62c3d87cb22a5550a649e450bb342a21db02b72
revert get catask configs from volume "subdirectory" in docker compose
2025-03-01 23:38:04 -05:00
max
b2b90ce4f5
get catask configs from volume "subdirectory" in docker compose 2025-03-01 23:38:04 -05:00
max
1dc7d087a7
allow empty root password in mariadb in docker compose 2025-03-01 23:38:04 -05:00
max
4a1de9417a
try to bind volume in docker compose 2025-03-01 23:38:04 -05:00
max
e0d9d1dba0
fix example config not copying to production config in dockerfile 2025-03-01 23:38:03 -05:00
max
6f430a885b
expose port 8000 in docker compose 2025-03-01 23:38:03 -05:00
max
944bb58bb3
add volumes to docker compose
add admin password arg
2025-03-01 23:38:03 -05:00
max
20a373a7ec
revert 196763057f7f531ec1df6b8bc5b62917fe6332a4
revert change db user and password to root
2025-03-01 23:38:02 -05:00
max
df399bd95b
add docker compose 2025-03-01 23:38:02 -05:00
max
d8d6dfda8a
change db user and password to root 2025-03-01 23:38:02 -05:00
max
7310a3cdb5
add dockerfile 2025-03-01 23:38:02 -05:00
mst
867575161a add .env stuff to update.md 2025-03-01 03:19:27 +03:00
mst
307cc6bee0 add database creation instructions 2025-03-01 03:14:31 +03:00
mst
bf80b8795c fix a filesystem error 2025-03-01 02:59:14 +03:00
mst
0d208d09c7 update default port in .env.example 2025-03-01 02:53:40 +03:00
mst
e36e44ea04 move to codeberg 2025-03-01 02:34:46 +03:00
mst
a83447df54
fix readme 2025-02-28 07:37:28 +03:00
mst
4204d3bce7
update instructions for 2.0.0 2025-02-28 07:35:53 +03:00
mst
1219105487
changelog for 2.0.0 2025-02-28 07:35:42 +03:00
mst
50de61fb24
some readme changes 2025-02-28 07:30:02 +03:00
mst
e7e6650ecd
update default config 2025-02-28 07:29:47 +03:00
mst
ac03277899
bump version, add social links 2025-02-28 07:29:38 +03:00
mst
65ed14858c
type annotations and return types, gif emoji support 2025-02-28 07:29:14 +03:00
mst
a65d89c301
update to-do 2025-02-28 07:28:14 +03:00
mst
47b4dca1c8
new footer, babel stuff, language dropdown 2025-02-28 07:28:00 +03:00
mst
8905f17d36
many changes for custom css 2025-02-28 07:27:34 +03:00
mst
9826b7f426
move nav links into a separate template 2025-02-28 07:27:04 +03:00
mst
93482cab33
babel stuff again 2025-02-28 07:26:35 +03:00
mst
d176ed0e02
languages page 2025-02-28 07:26:17 +03:00
mst
a0870fa8d8
some new static files 2025-02-28 07:26:07 +03:00
mst
f0e302b58a
emoji picker + textarea footer for 'ask a question' 2025-02-28 07:25:38 +03:00
mst
cadce98fc2
babel + slight layout change 2025-02-28 07:24:39 +03:00
mst
3b19d27fad
some question card style changes 2025-02-28 07:24:03 +03:00
mst
42459b0c74
auto-pagination 2025-02-28 07:23:33 +03:00
mst
80a46c9de9
question card layout setting 2025-02-28 07:22:59 +03:00
mst
2d2ba12379
some style updates 2025-02-28 07:22:35 +03:00
mst
691f0b0340
more babel stuff 2025-02-28 07:22:03 +03:00
mst
8f6db2fe11
script update 2025-02-28 07:18:11 +03:00
mst
ab726bc720
no spaces-only questions 2025-02-28 07:17:39 +03:00
mst
1bf3f038fc
add a language settings page 2025-02-28 07:17:14 +03:00
mst
de5b1240c6
split unread and total question count 2025-02-28 07:17:04 +03:00
mst
820638694d
theme store implementation 2025-02-28 07:16:38 +03:00
mst
ef0dd5b4fe
username input in general settings 2025-02-28 07:15:26 +03:00
mst
d1e2227658
better inbox layout 2025-02-28 07:15:07 +03:00
mst
2388c1a1ce
fix 2025-02-28 07:14:51 +03:00
mst
cb22ef5c23
babel stuff 2025-02-28 07:14:24 +03:00
mst
38b012c276
more postgres stuff 2025-02-28 07:09:13 +03:00
mst
6e2056a685
updating the code to work with postgres 2025-02-28 07:07:52 +03:00
mst
eca33dcbdd
babel 2025-02-28 07:03:42 +03:00
mst
5ceb96492b add changelog for 1.7.1 and 1.7.2 2024-12-17 16:07:43 +03:00
mst
acd8e7fc86 bump version 2024-12-17 16:07:29 +03:00
mst
46fd57a295 fix 2024-12-17 16:07:17 +03:00
mst
ab95963a88 select pinned questions first 2024-12-17 16:06:37 +03:00
mst
5f81017d79 use w-50 instead 2024-12-16 17:08:21 +03:00
mst
bf7e105b97 add update guide for 1.7.1 2024-12-16 16:57:36 +03:00
mst
307bf83ee8 bump version 2024-12-16 16:53:29 +03:00
mst
5c721c1524 use new render_markdown() function in inbox template 2024-12-16 16:53:05 +03:00
mst
cade47b04a simplify question card template 2024-12-16 16:52:44 +03:00
mst
502534131b use new render_markdown() function in question card, simplify template 2024-12-16 16:52:27 +03:00
mst
92e4fa9e82 simplify card text 2024-12-16 16:51:22 +03:00
mst
1273e5ba94 make admin pages prettier 2024-12-16 16:51:07 +03:00
mst
388bd29573 add error templates 2024-12-16 16:50:24 +03:00
mst
785273128d if question/answer is not found, return a 404 2024-12-16 16:50:12 +03:00
mst
71cbd9e54b improve markdown rendering, add error handlers, simplify index route logic 2024-12-16 16:49:42 +03:00
mst
4557b166bb getAllQuestions function 2024-12-16 16:48:31 +03:00
mst
cab955dbea don't add antispam scripts if antispam is not enabled 2024-12-16 16:46:30 +03:00
mst
65ac530e39 style fixes 2024-12-16 16:46:03 +03:00
mst
b2eac9654b add ntfy support 2024-12-16 16:45:41 +03:00
mst
91cd4b4c43 update guide for 1.7.0 2024-11-26 15:30:10 +03:00
mst
ad3adf6d16 changelog for 1.7.0 2024-11-26 15:27:21 +03:00
mst
9f166efc26 some new constants + bump version 2024-11-26 15:27:08 +03:00
mst
801153a9b9 update example config 2024-11-26 15:26:53 +03:00
mst
bbec16e926 add more stuff to roadmap 2024-11-26 15:26:31 +03:00
mst
01b960efc6 links to new pages + some other small stuff 2024-11-26 15:26:14 +03:00
mst
d886d1af58 add accessibility page template 2024-11-26 15:25:40 +03:00
mst
1beec55bac add anti-spam page template 2024-11-26 15:25:33 +03:00
mst
3211b6321e add import/export page template 2024-11-26 15:25:25 +03:00
mst
50bcdce6b3 add atkinson hyperlegible font 2024-11-26 15:25:08 +03:00
mst
70eb8dba0b slight css changes 2024-11-26 15:24:52 +03:00
mst
219269f090 awful switch color workaround 2024-11-26 15:24:38 +03:00
mst
475de66cec switches + new config option 2024-11-26 15:23:31 +03:00
mst
86fb42c6f6 add import/export functions 2024-11-26 15:16:42 +03:00
mst
283f2e5784 fix rendering of emojis with camelCase names 2024-11-26 15:16:14 +03:00
mst
ad4f34c7db slight ui improvements 2024-11-26 15:15:46 +03:00
mst
ea1b6f9bc6 use api.updateBlacklist instead of the same route 2024-11-26 15:15:30 +03:00
mst
008407b2f2 change return string text because why not 2024-11-26 15:15:13 +03:00
mst
74fd0ba646 remove debug prints and unused code 2024-11-26 15:14:57 +03:00
mst
b98cf142d7 add api routes for import/export and updating blacklist 2024-11-26 15:14:16 +03:00
mst
5f1f154789 add some new admin page routes 2024-11-26 15:13:55 +03:00
mst
f316d89e9d add unread question count 2024-11-26 15:13:17 +03:00
mst
bcf7972f77 add requests into imports 2024-11-26 15:12:36 +03:00
mst
7f615bbdf1 move addAnswer logic into functions 2024-11-26 15:11:59 +03:00
mst
b7cb3f8247 no inserting html tags into questions anymore 2024-11-26 15:11:13 +03:00
mst
9c9970b930 use app.logger.debug() instead of print() 2024-11-26 15:10:14 +03:00
mst
cac5ba5661 remove unused code 2024-11-26 15:09:38 +03:00
mst
8f9459c0a4 move @font-face out of here 2024-11-26 15:09:03 +03:00
mst
069e789978 css accessibility improvements 2024-11-26 15:08:43 +03:00
mst
a114076372 add more stuff to gitignore 2024-11-26 15:07:55 +03:00
mst
aae198496d some slight ui improvements 2024-11-26 15:07:40 +03:00
mst
f416e5cf9e tons of new and fixed js logic 2024-11-26 15:06:50 +03:00
mst
29379ca493 captcha support + remove row div 2024-11-26 15:05:05 +03:00
mst
87aaca7ae3 layout fix 2024-11-26 15:04:27 +03:00
mst
bd57f1834d show instance title in rs layout + layout fix 2024-11-26 15:04:16 +03:00
mst
341f3ab7d2 ui improvements + icons 2024-11-26 15:02:55 +03:00
mst
7a8fa642dc pwa manifest + atkinson hyperlegible support 2024-11-26 15:01:45 +03:00
mst
9f1b13c64f various inbox ui improvements 2024-11-26 15:01:18 +03:00
mst
0af8ce7648 see all emojis in a pack from the admin panel 2024-11-26 15:00:52 +03:00
mst
95f2f4e1a9 add an icon to delete button 2024-11-26 15:00:07 +03:00
mst
22493bdfc7 various normal layout improvements + captcha support 2024-11-26 14:59:42 +03:00
mst
650e402f84 various rs layout improvements 2024-11-26 14:59:06 +03:00
mst
e0e492211b add unread column to schema 2024-11-26 14:58:40 +03:00
mst
1e13fefcc6 move addQuestion func to functions.py + pwa manifest route 2024-11-26 14:58:19 +03:00
mst
d301606325 add more needed imports 2024-11-26 14:57:07 +03:00
mst
f9d0b8653d add userway into base template + font preload condition 2024-11-26 14:56:37 +03:00
mst
1b170a1c76 add requests to requirements 2024-11-26 14:54:04 +03:00
mst
3bc97b26ff some logic improvements 2024-11-26 14:53:48 +03:00
mst
2b52aef8d3 use my-1 instead of mt-1 mb-1 2024-11-26 14:53:25 +03:00
mst
6617d4fe29 stop flooding the DOM with modals 2024-11-26 14:53:06 +03:00
mst
a021702aad revamp single question view template 2024-11-26 14:52:08 +03:00
mst
d6acdb7919 improve install script a little bit 2024-11-26 14:47:02 +03:00
mst
bbe8994907 add appendToJSON() function 2024-11-26 14:45:41 +03:00
mst
30f90da265 this is not needed anymore 2024-11-26 14:44:35 +03:00
mst
3426cf359f honestly don't remember what i did there 2024-11-26 14:43:54 +03:00
mst
026c950a02 also update update.md 2024-10-20 01:04:49 +03:00
mst
29f1b19bba hotfix 2024-10-20 01:04:14 +03:00
mst
ea1d27cf81 add remaining files (moved/deleted) 2024-10-20 00:17:41 +03:00
mst
643f9e4efe update .gitignore 2024-10-20 00:16:40 +03:00
mst
29eee8d8a4 add update instructions for 1.6.0 2024-10-20 00:16:27 +03:00
mst
6fbb30f895 add changelog for 1.6.0 2024-10-20 00:16:00 +03:00
mst
7facd086ff some changes 2024-10-20 00:05:40 +03:00
mst
ffc2b30e27 huge overhaul of homepage template 2024-10-20 00:04:06 +03:00
mst
bd343be3d7 add cws and custom emojis support into inbox 2024-10-20 00:02:46 +03:00
mst
b671c1ea8f post-only login link and some other stuff 2024-10-20 00:01:09 +03:00
mst
c2dd774e60 new admin templates 2024-10-19 23:59:56 +03:00
mst
1e9973cfa8 update styles 2024-10-19 23:59:12 +03:00
mst
b7208381cb update schema to support content warnings 2024-10-19 23:58:35 +03:00
mst
b412418827 add some stuff into roadmap 2024-10-19 23:58:04 +03:00
mst
9cd1758c4f remove flask-limiter from requirements (for now) 2024-10-19 23:57:37 +03:00
mst
ee40d1b73f rendering of custom emojis, etc 2024-10-19 23:57:10 +03:00
mst
b24e3ced2d bump version, add a new constant 2024-10-19 23:56:06 +03:00
mst
5ee3422926 update example config 2024-10-19 23:55:28 +03:00
mst
ca7e584922 add custom emojis, split admin panel, debug logging, etc (app.py) 2024-10-19 23:54:27 +03:00
64 changed files with 7287 additions and 1048 deletions

View file

@ -2,6 +2,6 @@ DB_HOST = 127.0.0.1
DB_NAME = catask
DB_USER =
DB_PASS =
DB_PORT = 3306
DB_PORT = 5432
ADMIN_PASSWORD =
APP_SECRET =

6
.gitignore vendored
View file

@ -6,3 +6,9 @@ word_blacklist.txt
static/icons/favicon/*.*
!static/icons/favicon/default
install.sh
static/emojis/*
ip_blacklist.txt
ca*.zip
static/exports/*
*.log
exports.json

View file

@ -1,3 +1,123 @@
## 2.0.0
### Breaking changes
* switch to postgresql
### New features
#### Big
* custom theme support
* * theme store
* * the standalone theme store component
* * css editor with syntax highlighting (codemirror 6)
* * theme import
* * theme export
* translation support
* language settings in admin panel
#### Moderate
* emoji picker
* loading more questions as you scroll instead of loading them all at once
* new compact question card layout as an option
#### Small
* option to add a username before date in the question box
### Fixes
* fixed a minor styling issue on 'view question' page
### Miscellaneous
* gif emojis are now supported
* code cleanup
* type annotations and return types in functions
* emoji pack names aren't automatically capitalized anymore (this doesn't apply to packs uploaded before the update)
* revamped 'add question' box
* better tooltip styling
* better footer with more links
## 1.7.2
### Fixes
* pinned questions were not appearing on top
## 1.7.1
### New features
* ntfy support
### Fixes
* fixed markdown escaping
### Miscellaneous
* custom error templates
* prettier admin pages ui
* some templates were simplified to make DOM size smaller
* some style fixes
## 1.7.0
### New features
* import/export of catask data
* captcha support for spam prevention: recaptcha v2, cloudflare turnstile, and friendly captcha
* more customization
* initial PWA support
* ability to see all emojis in a pack from the admin panel
* unread questions indicator
### Accessibility
* support for Atkinson Hyperlegible font (**Admin -> Accessibility -> Font**)
* [UserWay](https://userway.org) support (**Admin -> Accessibility -> UserWay**)
* other accessibility improvements
### Fixes
* emojis with camelCase names (e.g. floofHappy) now render correctly
* various layout fixes for wide and narrow screens
* share to fediverse button in single question view now works
* various other fixes
* replaced some debug `print()` statements with `app.logger.debug()` statements to not spam production console
### Miscellaneous
* improved modal ui
* more of the logic is now handled in API routes
* checkboxes in admin panel are now styled like switches
* removed some unused code
## 1.6.1
### Fixes
* emojis settings in admin panel causing a server error
## 1.6.0
### New features
* custom emojis & emoji packs
* optional retrospring layout for people migrating from it
* content warnings
* overhaul of admin panel ui
### Security
* logout route now only accepts `POST` requests to (partially) prevent CSRF attacks
### Fixes
* "return to inbox" option no longer triggers a notification twice
### Regressions
* there is no preview in the admin panel anymore because it doesn't fit anymore
### Miscellaneous
* internal overhaul of page templates
* added some debug logging (only visible if you run a development server like `flask run`)
there is probably more that i forgot but it's 11 pm rn and i can't be bothered with that
## 1.5.6
### Fixes

7
Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM python:3-alpine
WORKDIR /catask
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
ENTRYPOINT [ "gunicorn", "-w", "4", "app:app", "--bind", "0.0.0.0:8000" ]

View file

@ -1,16 +1,16 @@
# ![CatAsk icon](./static/icons/catask-32.png) CatAsk
a work-in-progress minimal single-user q&a software
> [!NOTE]
> CatAsk is alpha software, therefore bugs are expected to happen
a simple & easy to use Q&A software that makes answering questions easier
## Prerequisites
- MySQL/MariaDB
- PostgreSQL
- Python 3.10+ (3.12+ recommended)
## Install
Clone this repository: `git clone https://git.gay/mst/catask.git`
Clone this repository: `git clone https://codeberg.org/catask-org/catask.git`
### Docker
See [docker.md](./docker.md) for install instructions
### VPS-specific
Go into the cloned repository, create a virtual environment and activate it:
@ -26,6 +26,14 @@ Go into the cloned repository, create a virtual environment and activate it:
After that, install required packages:
```pip install -r requirements.txt```
Then, create the database and the user for CatAsk:
``` sql
CREATE USER '<DB_USER>' WITH PASSWORD "<DB_PASS>";
```
``` sql
CREATE DATABASE "<DB_NAME>" OWNER '<DB_USER>';
```
### 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,12 +48,12 @@ 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)
`DB_PORT` - database port (usually 5432)
`ADMIN_PASSWORD` - password to access admin panel
`APP_SECRET` - application secret, generate one with this command: `python3 -c 'import secrets; print(secrets.token_hex())'`
### config.json
Configure in Admin panel after installing (located at `https://yourdomain.tld/admin/`)
Configure in Admin panel after installing
---
@ -61,11 +69,10 @@ gunicorn -w 4 app:app
If you want CatAsk to be accessible on a specific host address, specify a `--bind` option to `gunicorn -w 4 app:app` (e.g. `--bind 127.0.0.1:5000`)
For debugging, run `flask run --debug`
Admin login page is located at `https://example.com/admin/login/`
Runs on `127.0.0.1:8000` by default
### 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
For instructions with updating from one version to another, check [UPDATE.md](./UPDATE.md) file
Check [CHANGELOG.md](./CHANGELOG.md) file for release notes

169
UPDATE.md
View file

@ -1,4 +1,169 @@
## 1.4.x -> 1.5.0
# Updating
## 1.7.x -> 2.0.0
### prerequisites
1. create an export of your data in **Admin -> Import/Export -> New Export** and download it in case something breaks
2. install postgresql (if you don't have it): [postgresql.org](https://www.postgresql.org/download/)
3. install pgloader: [pgloader.readthedocs.io](https://pgloader.readthedocs.io/en/latest/install.html)
4. start postgresql
### performing the upgrade
1. create a new postgresql user:
```sql
CREATE USER "<username>" WITH PASSWORD "<password>";
```
1. create a new postgresql database:
```sql
CREATE DATABASE "<db name>" OWNER "<db user>";
```
2. run pgloader:
```sh
pgloader mysql://old_user:old_password@127.0.0.1/old_catask pgsql://new_user:new_password@127.0.0.1/new_catask
```
3. pull the update: `git pull`
4. make the following changes in your config.json file:
```diff
...
"accessibility": {
"font": "default",
"userway": {
"enabled": false,
"account": "p3mmiCKHVO"
}
},
+ "languages": {
+ "default": "en_US",
+ "allowChanging": true
+ },
"style": {
"accentLight": "#b86565",
"accentDark": "#b86565",
"bgLight": "#ffffff",
"bgDark": "#202020",
"navStyle": "pills",
"tintColors": true,
"infoBoxLayout": "row",
"homepageLayout": "catask",
"navIcons": true,
"navIconsOnly": false,
+ "customCss": "",
+ "useCustomCss": false,
+ "overrideBaseStyles": false,
+ "overrideCatAskStyles": false,
+ "cardStyle": "compact"
},
...
"ntfy": {
"enabled": false,
"host": "https://ntfy.sh",
"user": "",
"pass": "",
"topic": ""
},
+ "themeStoreUrl": "https://themes.catask.mystie.dev",
+ "username": ""
...
```
5. modify your .env file to have new database credentials and change database port to 5432 (default for postgres)
## 1.7.0 -> 1.7.x
pull the update: `git pull`
make the following changes in your config.json file:
```diff
...
"antispam": {
...
},
+ "ntfy": {
+ "enabled": false,
+ "host": "https://ntfy.sh",
+ "user": "",
+ "pass": "",
+ "topic": ""
+ },
...
```
## 1.6.x -> 1.7.0
pull the update: `git pull`
run `pip install -r requirements.txt` again to install newly required packages
make the following changes in your config.json file:
```diff
...
"instance": {
...
},
+ "accessibility": {
+ "font": "default",
+ "userway": {
+ "enabled": false,
+ "account": ""
+ }
+ },
"style": {
...
"homepageLayout": "...",
+ "navIcons": true,
+ "navIconsOnly": false
},
+ "antispam": {
+ "type": "basic",
+ "enabled": true,
+ "recaptcha": {
+ "sitekey": "",
+ "secretkey": ""
+ },
+ "turnstile": {
+ "sitekey": "",
+ "secretkey": ""
+ },
+ "frc": {
+ "sitekey": "",
+ "apikey": ""
+ }
+ },
...
```
after that, log into your mariadb/mysql console and execute these commands:
```
USE <catask database name>;
```
```
ALTER TABLE questions ADD COLUMN unread BOOLEAN NOT NULL DEFAULT TRUE;
```
## 1.5.x -> 1.6.x
pull the update: `git pull`
make these changes in your config.json file:
```diff
...
"style": {
...
+ "homepageLayout": "catask"
},
...
```
after that, log into your mariadb/mysql console and execute these commands:
```
USE <catask database name>;
```
```
ALTER TABLE questions ADD COLUMN cw VARCHAR(255) NOT NULL DEFAULT '';
```
```
ALTER TABLE answers ADD COLUMN cw VARCHAR(255) NOT NULL DEFAULT '';
```
## 1.4.x -> 1.5.x
pull the update as usual: `git pull`
run `pip install -r requirements.txt` again to install newly required packages
@ -8,7 +173,7 @@ make the following changes in your config.json file:
"instance": {
...
- "image": "/static/img/ca_screenshot.png",
+ "image": "/static/icons/favicon/android-chrome-192x192.png",
+ "image": "/static/icons/favicon/android-chrome-512x512.png",
...
},
+ "style": {

804
app.py

File diff suppressed because it is too large Load diff

2
babel.cfg Normal file
View file

@ -0,0 +1,2 @@
[python: *.py]
[jinja2: **/templates/**.html]

View file

@ -2,24 +2,68 @@
"instance": {
"title": "CatAsk",
"description": "Ask me something!",
"image": "/static/icons/favicon/apple-touch-icon.png",
"image": "/static/icons/favicon/android-chrome-512x512.png",
"fullBaseUrl": "https://catask.localhost",
"rules": ""
},
"accessibility": {
"font": "default",
"userway": {
"enabled": false,
"account": ""
}
},
"languages": {
"default": "en_US",
"allowChanging": true
},
"style": {
"accentLight": "#6345d9",
"accentDark": "#7259d9",
"accentDark": "#7a63e3",
"bgLight": "#ffffff",
"bgDark": "#202020",
"navStyle": "underline",
"navStyle": "pills",
"tintColors": false,
"infoBoxLayout": "column"
"infoBoxLayout": "row",
"homepageLayout": "catask",
"navIcons": true,
"navIconsOnly": false,
"customCss": "",
"useCustomCss": false,
"overrideBaseStyles": false,
"overrideCatAskStyles": false,
"cardStyle": "compact"
},
"trimContentAfter": "50",
"antispam": {
"type": "basic",
"enabled": true,
"recaptcha": {
"sitekey": "",
"secretkey": ""
},
"turnstile": {
"sitekey": "",
"secretkey": ""
},
"frc": {
"sitekey": "",
"apikey": ""
}
},
"ntfy": {
"enabled": false,
"host": "https://ntfy.sh",
"user": "",
"pass": "",
"topic": ""
},
"themeStoreUrl": "http://127.0.0.1:8000",
"username": "",
"trimContentAfter": "150",
"charLimit": "512",
"anonName": "Anonymous",
"lockInbox": false,
"allowAnonQuestions": true,
"showQuestionCount": false,
"showQuestionCount": true,
"noDeleteConfirm": false
}

View file

@ -2,9 +2,22 @@ from pathlib import Path
antiSpamFile = 'wordlist.txt'
blacklistFile = 'word_blacklist.txt'
# reserved for 1.7.0 or later
# ipBlacklistFile = 'ip_blacklist.txt'
configFile = 'config.json'
exportsFile = 'exports.json'
faviconDir = Path.cwd() / 'static' / 'icons' / 'favicon'
tempDir = Path.cwd() / 'static' / 'temp'
exportsDir = Path('static') / 'exports'
emojiPath = Path.cwd() / 'static' / 'emojis'
appName = 'CatAsk'
version = '1.5.6'
version = '2.0.0'
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
version_id = '-alpha'
version_id = '-stable'
repoUrl = "https://codeberg.org/catask-org/catask"
homepageUrl = "https://catask.mystie.dev"
docsUrl = "https://docs.catask.mystie.dev"
social = {
"bskyUrl": "https://bsky.app/profile/catask.mystie.dev",
"fediUrl": "https://icy.maxy.top/@CatAsk"
}

45
docker-compose.yml Normal file
View file

@ -0,0 +1,45 @@
name: catask
services:
postgres:
environment:
POSTGRES_DB: catask
POSTGRES_USER: catask
POSTGRES_PASSWORD: catask
healthcheck:
test: ["CMD", "pg_isready", "-U", "catask"]
interval: 1s
timeout: 5s
retries: 10
image: postgres:alpine
networks:
- catask
restart: always
volumes:
- ./schema.sql:/docker-entrypoint-initdb.d/catask.sql
- db-data:/var/lib/postgresql/data
catask:
build:
dockerfile: Dockerfile
depends_on:
postgres:
condition: service_healthy
networks:
- catask
ports:
- "8000:8000"
restart: always
volumes:
- catask-data:/catask/static/emojis
- catask-data:/catask/static/icons/favicon
- ./config.json:/catask/config.json
- ./.env:/catask/.env
networks:
catask:
driver: bridge
volumes:
db-data:
catask-data:

54
docker.md Normal file
View file

@ -0,0 +1,54 @@
# CatAsk on Docker (or Podman)
## Prerequisites
- Docker + `docker-compose` (or Podman + `podman-compose`)
## Steps
Before starting CatAsk, you must copy the configuration files to their proper places first:
```sh
cp config.example.json config.json
cp .env.example .env
```
Then, paste this into the `.env` file, and replace sections that are marked with `[CHANGE THIS]`.
```env
DB_HOST = postgres
DB_NAME = catask
DB_USER = catask
DB_PASS = catask
DB_PORT = 5432
ADMIN_PASSWORD = [CHANGE THIS]
APP_SECRET = [CHANGE THIS]
```
You may now start CatAsk:
```sh
docker compose up
```
If you have done everything correctly, going to `http://localhost:8000` in your browser should show a question box screen. You may now log in with your admin password, and configure the instance.
## Updating
1. Stop the Docker container
```sh
docker compose down
```
2. Follow the [`UPDATE.md`](./UPDATE.md) file to see what to add or remove to your `config.json` or `.env`.
3. Remove the Docker image
```sh
docker rmi catask_catask
```
4. Restart CatAsk
```sh
docker compose up
```

View file

@ -1,18 +1,48 @@
from flask import url_for, request
from flask import url_for, request, jsonify, Flask, abort, session
from flask_babel import Babel, _, refresh
from markupsafe import Markup
from bleach.sanitizer import Cleaner
from datetime import datetime
from datetime import datetime, timezone
from pathlib import Path
from mistune import HTMLRenderer, escape
from PIL import Image
from psycopg.rows import dict_row
import base64
import time
import zipfile
import shutil
import subprocess
import mistune
import humanize
import mysql.connector
import psycopg
import re
import os
import random
import json
import requests
import constants as const
app = Flask(const.appName)
app.config['BABEL_DEFAULT_LOCALE'] = 'en'
app.config['BABEL_TRANSLATION_DIRECTORIES'] = 'locales'
# refreshing locale
refresh()
# update this once more languages are supported
app.config['available_languages'] = {
"en_US": _("English (US)"),
"ru_RU": _("Russian")
}
def getLocale():
if not session.get('language'):
app.config.update(cfg)
session['language'] = cfg['languages']['default']
return session.get('language')
babel = Babel(app, locale_selector=getLocale)
# load json file
def loadJSON(file_path):
# open the file
@ -29,17 +59,61 @@ def saveJSON(dict, file_path):
# dump the contents
json.dump(dict, file, indent=4)
# append to a json file
def appendToJSON(new_data, file_path) -> bool:
try:
# open the file
path = Path(file_path)
if not path.is_file():
with open(path, 'w', encoding="utf-8") as file:
json.dump([], file)
with open(path, 'r+', encoding="utf-8") as file:
file_data = json.load(file)
file_data.append(new_data)
file.seek(0)
json.dump(file_data, file, indent=4)
return True
except Exception as e:
app.logger.error(str(e))
return False
cfg = loadJSON(const.configFile)
def formatRelativeTime(date_str):
def formatRelativeTime(date_str: str) -> str:
date_format = "%Y-%m-%d %H:%M:%S"
past_date = datetime.strptime(date_str, date_format)
past_date = datetime.strptime(date_str, date_format).replace(tzinfo=None)
now = datetime.now()
time_difference = now - past_date
return humanize.naturaltime(time_difference)
def formatRelativeTime2(date_str: str) -> str:
date_format = "%Y-%m-%dT%H:%M:%SZ"
past_date = None
try:
if date_str:
past_date = datetime.strptime(date_str, date_format)
else:
pass
except ValueError:
pass
if past_date is None:
return ''
# raise ValueError("Date string does not match any supported format.")
if past_date.tzinfo is None:
past_date = past_date.replace(tzinfo=timezone.utc)
now = datetime.now(timezone.utc)
time_difference = now - past_date
return humanize.naturaltime(time_difference)
dbHost = os.environ.get("DB_HOST")
dbUser = os.environ.get("DB_USER")
dbPass = os.environ.get("DB_PASS")
@ -48,58 +122,239 @@ dbPort = os.environ.get("DB_PORT")
if not dbPort:
dbPort = 3306
def createDatabase(cursor, dbName):
def createDatabase(cursor, dbName) -> None:
try:
cursor.execute("CREATE DATABASE {} DEFAULT CHARACTER SET 'utf8'".format(dbName))
cursor.execute("CREATE DATABASE {} OWNER {}".format(dbName, dbUser))
print(f"Database {dbName} created successfully")
except mysql.connector.Error as error:
except psycopg.Error as error:
print("Failed to create database:", error)
exit(1)
def connectToDb():
conn = mysql.connector.connect(
host=dbHost,
user=dbUser,
password=dbPass,
database=dbName,
port=dbPort,
autocommit=True
)
return conn
# using dict_row factory here because its easier than modifying now-legacy mysql code
return psycopg.connect(f"postgresql://{dbUser}:{dbPass}@{dbHost}/{dbName}", row_factory=dict_row)
def getQuestion(question_id: int):
def getQuestion(question_id: int) -> dict:
conn = connectToDb()
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor()
cursor.execute("SELECT * FROM questions WHERE id=%s", (question_id,))
question = cursor.fetchone()
question['creation_date'] = question['creation_date'].replace(microsecond=0).replace(tzinfo=None)
cursor.close()
conn.close()
return question
def getAnswer(question_id: int):
def getAllQuestions(limit: int = None, offset: int = None) -> dict:
conn = connectToDb()
cursor = conn.cursor(dictionary=True)
cursor = conn.cursor()
app.logger.debug("[CatAsk/functions/getAllQuestions] SELECT'ing all questions with latest answers")
query = """
SELECT q.*, a.creation_date AS latest_answer_date
FROM questions q
LEFT JOIN (
SELECT question_id, MAX(creation_date) AS creation_date
FROM answers
GROUP BY question_id
) a ON q.id = a.question_id
WHERE q.answered = %s
ORDER BY q.pinned DESC, (a.creation_date IS NULL), a.creation_date DESC, q.creation_date DESC
"""
params = [True]
if limit is not None:
query += " LIMIT %s"
params.append(limit)
if offset is not None:
query += " OFFSET %s"
params.append(offset)
cursor.execute(query, tuple(params))
questions = cursor.fetchall()
app.logger.debug("[CatAsk/functions/getAllQuestions] SELECT'ing answers")
cursor.execute("SELECT * FROM answers ORDER BY creation_date DESC")
answers = cursor.fetchall()
metadata = generateMetadata()
combined = []
for question in questions:
question['creation_date'] = question['creation_date'].replace(microsecond=0).replace(tzinfo=None)
for answer in answers:
answer['creation_date'] = answer['creation_date'].replace(microsecond=0).replace(tzinfo=None)
question_answers = [answer for answer in answers if answer['question_id'] == question['id']]
combined.append({
'question': question,
'answers': question_answers
})
cursor.close()
conn.close()
return combined, metadata
def addQuestion(from_who: str, question: str, cw: str, noAntispam: bool = False) -> dict:
if cfg['antispam']['type'] == 'basic':
antispam = request.form.get('antispam', '')
elif cfg['antispam']['type'] == 'recaptcha':
antispam = request.form.get('g-recaptcha-response', '')
elif cfg['antispam']['type'] == 'turnstile':
antispam = request.form.get('cf-turnstile-response', '')
elif cfg['antispam']['type'] == 'frc':
antispam = request.form.get('frc-captcha-response', '')
if cfg['antispam']['enabled'] and not noAntispam:
if cfg['antispam']['type'] == 'basic':
if not antispam:
abort(400, "Anti-spam word must not be empty")
antispam_wordlist = readPlainFile(const.antiSpamFile, split=True)
antispam_valid = antispam in antispam_wordlist
if not antispam_valid:
# return a generic error message so bad actors wouldn't figure out the antispam list
return {'error': _('An error has occurred')}, 500
# it's probably bad to hardcode the siteverify urls, but meh, that will do for now
elif cfg['antispam']['type'] == 'recaptcha':
r = requests.post(
'https://www.google.com/recaptcha/api/siteverify',
data={'response': antispam, 'secret': cfg['antispam']['recaptcha']['secretkey']}
)
json_r = r.json()
success = json_r['success']
if not success:
return {'error': _('An error has occurred')}, 500
elif cfg['antispam']['type'] == 'turnstile':
r = requests.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={'response': antispam, 'secret': cfg['antispam']['turnstile']['secretkey']}
)
json_r = r.json()
success = json_r['success']
if not success:
return {'error': _('An error has occurred')}, 500
elif cfg['antispam']['type'] == 'frc':
url = 'https://global.frcapi.com/api/v2/captcha/siteverify'
headers = {'X-API-Key': cfg['antispam']['frc']['apikey']}
data = {'response': antispam, 'sitekey': cfg['antispam']['frc']['sitekey']}
r = requests.post(url, data=data, headers=headers)
json_r = r.json()
success = json_r['success']
if not success:
return {'error': _('An error has occurred')}, 500
blacklist = readPlainFile(const.blacklistFile, split=True)
for bad_word in blacklist:
if bad_word in question or bad_word in from_who:
# return a generic error message so bad actors wouldn't figure out the blacklist
return {'error': _('An error has occurred')}, 500
conn = connectToDb()
cursor = conn.cursor()
app.logger.debug("[CatAsk/API/add_question] INSERT'ing new question into database")
cursor.execute("INSERT INTO questions (from_who, content, answered, cw) VALUES (%s, %s, %s, %s) RETURNING id", (from_who, question, False, cw))
question_id = cursor.fetchone()['id']
conn.commit()
cursor.close()
conn.close()
return {'message': _('Question asked successfully!')}, 201, question_id
def getAnswer(question_id: int) -> dict:
conn = connectToDb()
cursor = conn.cursor()
cursor.execute("SELECT * FROM answers WHERE question_id=%s", (question_id,))
answer = cursor.fetchone()
answer['creation_date'] = answer['creation_date'].replace(microsecond=0).replace(tzinfo=None)
cursor.close()
conn.close()
return answer
def addAnswer(question_id: int, answer: str, cw: str) -> dict:
conn = connectToDb()
try:
cursor = conn.cursor()
app.logger.debug("[CatAsk/API/add_answer] INSERT'ing an answer into database")
cursor.execute("INSERT INTO answers (question_id, content, cw) VALUES (%s, %s, %s) RETURNING id", (question_id, answer, cw))
answer_id = cursor.fetchone()['id']
app.logger.debug("[CatAsk/API/add_answer] UPDATE'ing question to set answered and answer_id")
cursor.execute("UPDATE questions SET answered=%s, answer_id=%s WHERE id=%s", (True, answer_id, question_id))
conn.commit()
# except Exception as e:
# conn.rollback()
# app.logger.error(e)
# return jsonify({'error': str(e)}), 500
finally:
cursor.close()
conn.close()
return jsonify({'message': _('Answer added successfully!')}), 201
def ntfySend(cw, return_val, from_who, question) -> None:
app.logger.debug("[CatAsk/functions/ntfySend] started ntfy flow")
ntfy_cw = f" [CW: {cw}]" if cw else ""
ntfy_host = cfg['ntfy']['host']
ntfy_topic = cfg['ntfy']['topic']
question_id = return_val[2]
# doesn't work otherwise
from_who = from_who if from_who else cfg['anonName']
if cfg['ntfy']['user'] and cfg['ntfy']['pass']:
ntfy_user = cfg['ntfy']['user']
ntfy_pass = cfg['ntfy']['pass']
ascii_auth = f"{ntfy_user}:{ntfy_pass}".encode('ascii')
b64_auth = base64.b64encode(ascii_auth)
# there's probably a better way to do this without duplicated code
headers={
"Authorization": f"Basic {b64_auth.decode('ascii')}",
"Title": f"New question from {from_who}{ntfy_cw}",
"Actions": f"view, View question, {cfg['instance']['fullBaseUrl']}/inbox/#question-{question_id}",
"Tags": "question"
}
else:
headers={
"Title": f"New question from {from_who}{ntfy_cw}",
"Actions": f"view, View question, {cfg['instance']['fullBaseUrl']}/inbox/#question-{question_id}",
"Tags": "question"
}
r = requests.put(
f"{ntfy_host}/{ntfy_topic}".encode('utf-8'),
data=trimContent(question, int(cfg['trimContentAfter'])),
headers=headers
)
app.logger.debug("[CatAsk/functions/ntfySend] finished ntfy flow")
def readPlainFile(file, split=False):
if os.path.exists(file):
with open(file, 'r', encoding="utf-8") as file:
if split == False:
return file.read()
if split == True:
if split:
return file.read().splitlines()
else:
return file.read()
else:
return []
def getRandomWord():
def savePlainFile(file, contents) -> None:
with open(file, 'w') as file:
file.write(contents)
def getRandomWord() -> str:
items = readPlainFile(const.antiSpamFile, split=True)
return random.choice(items)
def trimContent(var, trim):
def trimContent(var, trim) -> str:
trim = int(trim)
if trim > 0:
trimmed = var[:trim] + '' if len(var) >= trim else var
@ -117,19 +372,178 @@ def parse_inline_button(inline, m, state):
return m.end()
def render_inline_button(renderer, text):
return f"<button class='btn btn-outline-secondary'>{text}</button>"
return f"<button class='btn btn-secondary' type='button'>{text}</button>"
def button(md):
md.inline.register('inline_button', inlineBtnPattern, parse_inline_button, before='link')
if md.renderer and md.renderer.NAME == 'html':
md.renderer.register('inline_button', render_inline_button)
def renderMarkdown(text):
# Base directory where emoji packs are stored
EMOJI_BASE_PATH = Path.cwd() / 'static' / 'emojis'
emoji_cache = {}
def to_snake_case(name):
name = re.sub(r'(.)([A-Z][a-z]+)', r'\1_\2', name)
return re.sub(r'([a-z0-9])([A-Z])', r'\1_\2', name).lower()
def find_emoji_path(emoji_name):
if '_' in emoji_name:
head, sep, tail = emoji_name.partition('_')
else:
head = to_snake_case(emoji_name).split('_')[0]
if any(Path(EMOJI_BASE_PATH).glob(f'{head}.json')):
for json_file in Path(EMOJI_BASE_PATH).glob('*.json'):
app.logger.debug("[CatAsk/functions/find_emoji_path] Using JSON meta file")
pack_data = loadJSON(json_file)
emojis = pack_data.get('emojis', [])
for emoji in emojis:
if emoji['name'] == emoji_name:
rel_dir = json_file.stem
emoji_path = os.path.join('static/emojis', rel_dir, emoji['file_name'])
emoji_cache[emoji_name] = emoji_path
return emoji_path
else:
for address, dirs, files in os.walk(EMOJI_BASE_PATH):
app.logger.debug("[CatAsk/functions/find_emoji_path] Falling back to scanning directories")
for file in files:
if os.path.splitext(file)[0] == emoji_name: # Check if the filename matches the emoji_name
rel_dir = os.path.relpath(address, EMOJI_BASE_PATH)
emoji_path = os.path.join("static/emojis", rel_dir, file) # Use the actual file name
emoji_cache[emoji_name] = emoji_path
return emoji_path
return None
emojiPattern = r':(?P<emoji_name>[a-zA-Z0-9_]+):'
def parse_emoji(inline, m, state):
emoji_name = m.group("emoji_name")
state.append_token({"type": "emoji", "raw": emoji_name})
return m.end()
def render_emoji(renderer, emoji_name):
emoji_path = find_emoji_path(emoji_name)
if emoji_path:
absolute_emoji_path = url_for('static', filename=emoji_path.replace('static/', ''))
return f"<img src='{absolute_emoji_path}' alt=':{emoji_name}:' title=':{emoji_name}:' class='emoji' loading='lazy' width='28' height='28' />"
return f":{emoji_name}:"
def emoji(md):
md.inline.register('emoji', emojiPattern, parse_emoji, before='link')
if md.renderer and md.renderer.NAME == 'html':
md.renderer.register('emoji', render_emoji)
def listEmojis() -> list:
emojis = []
emoji_base_path = Path.cwd() / 'static' / 'emojis'
os.makedirs(emoji_base_path, exist_ok=True)
# Iterate over files that are directly in the emoji base path (not in subdirectories)
for file in emoji_base_path.iterdir():
# Only include files, not directories
if file.is_file() and file.suffix in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
# Get the relative path and name for the emoji
relative_path = os.path.relpath(file, emoji_base_path)
emojis.append({
'name': file.stem, # Get the file name without the extension
'image': os.path.join('static/emojis', relative_path), # Full relative path for image
'relative_path': relative_path
})
return emojis
def listEmojiPacks() -> list:
emoji_packs = []
emoji_base_path = const.emojiPath
# Iterate through all directories in the emoji base path
for pack_dir in emoji_base_path.iterdir():
if pack_dir.is_dir():
relative_path = os.path.relpath(pack_dir, emoji_base_path)
# Check if a meta.json file exists in the directory
meta_json_path = const.emojiPath / f"{pack_dir}.json"
if meta_json_path.exists():
app.logger.debug(f"[CatAsk/functions/listEmojiPacks] Using meta.json file ({meta_json_path})")
# Load data from the meta.json file
pack_data = loadJSON(meta_json_path)
emoji_packs.append({
'name': pack_data.get('name', pack_dir.name),
'exportedAt': pack_data.get('exportedAt', 'Unknown'),
'preview_image': pack_data.get('preview_image', ''),
'website': pack_data.get('website', ''),
'relative_path': f'static/emojis/{relative_path}',
'emojis': pack_data.get('emojis', [])
})
else:
app.logger.debug(f"[CatAsk/functions/listEmojiPacks] Falling back to directory scan ({pack_dir})")
# If no meta.json is found, fall back to directory scan
preview_image = None
# Find the first image in the directory for preview
for file in pack_dir.iterdir():
if file.suffix in {'.png', '.jpg', '.jpeg', '.webp', '.gif'}:
preview_image = os.path.join('static/emojis', relative_path, file.name)
break
# Append pack info without meta.json
emoji_packs.append({
'name': pack_dir.name,
'preview_image': preview_image,
'relative_path': f'static/emojis/{relative_path}'
})
return emoji_packs
def processEmojis(meta_json_path) -> list:
emoji_metadata = loadJSON(meta_json_path)
emojis = emoji_metadata.get('emojis', [])
pack_name = emoji_metadata['emojis'][0]['emoji']['category']
exported_at = emoji_metadata.get('exportedAt', 'Unknown')
website = emoji_metadata.get('host', '')
preview_image = os.path.join('static/emojis', pack_name.lower(), emoji_metadata['emojis'][0]['fileName'])
relative_path = os.path.join('static/emojis', pack_name.lower())
processed_emojis = []
for emoji in emojis:
emoji_info = {
'name': emoji['emoji']['name'],
'file_name': emoji['fileName'],
}
processed_emojis.append(emoji_info)
app.logger.debug(f"[CatAsk/API/upload_emoji_pack] Processed emoji: {emoji_info['name']}\t(File: {emoji_info['file_name']})")
# Create the pack info structure
pack_info = {
'name': pack_name,
'exportedAt': exported_at,
'preview_image': preview_image,
'relative_path': relative_path,
'website': website,
'emojis': processed_emojis
}
# Save the combined pack info to <pack_name>.json
pack_json_name = const.emojiPath / f"{pack_name.lower()}.json"
saveJSON(pack_info, pack_json_name)
return processed_emojis
def renderMarkdown(text: str, allowed_tags: bool = None) -> str:
plugins = [
'strikethrough',
button
button,
emoji
]
if not allowed_tags:
allowed_tags = [
'p',
'em',
@ -143,11 +557,23 @@ def renderMarkdown(text):
'button',
'ol',
'li',
'hr'
'hr',
'img',
'code',
'pre'
]
allowed_attrs = {
'a': 'href',
'button': 'class'
'button': 'class',
'img': {
'class': lambda value: value == "emoji",
'src': True, # Allow specific attributes on emoji images
'alt': True,
'title': True,
'width': True,
'height': True,
'loading': True
}
}
# hard_wrap=True means that newlines will be
# converted into <br> tags
@ -166,7 +592,7 @@ def renderMarkdown(text):
clean_html = cleaner.clean(html)
return Markup(clean_html)
def generateMetadata(question=None, answer=None):
def generateMetadata(question: str = None, answer: str = None) -> dict:
metadata = {
'title': cfg['instance']['title'],
'description': cfg['instance']['description'],
@ -186,12 +612,23 @@ def generateMetadata(question=None, answer=None):
# return 'metadata' dictionary
return metadata
allowedFileExtensions = {'png', 'jpg', 'jpeg', 'webp', 'bmp', 'jxl'}
allowedFileExtensions = {'png', 'jpg', 'jpeg', 'webp', 'bmp', 'jxl', 'gif'}
allowedArchiveExtensions = {'zip', 'tar', 'gz', 'bz2', 'xz'}
def allowedFile(filename):
def allowedFile(filename: str) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowedFileExtensions
def generateFavicon(file_name):
def allowedArchive(filename: str) -> bool:
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowedArchiveExtensions
def stripArchExtension(filename: str) -> str:
if filename.endswith(('.tar.gz', '.tar.bz2', '.tar.xz')):
filename = filename.rsplit('.', 2)[0]
else:
filename = filename.rsplit('.', 1)[0]
return filename
def generateFavicon(file_name: str) -> None:
sizes = {
'apple-touch-icon.png': (180, 180),
'android-chrome-192x192.png': (192, 192),
@ -210,3 +647,165 @@ def generateFavicon(file_name):
resized_img = img.resize(size)
resized_img_absolute_path = const.faviconDir / filename
resized_img.save(resized_img_absolute_path)
def createExport() -> dict:
try:
# just to test if connection works
conn = connectToDb()
conn.close()
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
timestamp_morereadable = datetime.now().strftime('%b %d, %Y %H:%M')
export_dir = const.exportsDir
temp_dir = const.tempDir
os.makedirs(export_dir, exist_ok=True)
os.makedirs(temp_dir, exist_ok=True)
config_dest_path = temp_dir / const.configFile
shutil.copy(const.configFile, config_dest_path)
# Export database to SQL file
dump_file = temp_dir / 'database.sql'
result = subprocess.Popen(
f'pg_dump -U {dbUser} -d {dbName} -F c -E UTF8 -f {dump_file}',
stdin=subprocess.PIPE,
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
env=dict(os.environ, PGPASSWORD=dbPass)
)
# absolutely dumb workaround for an error
time.sleep(1)
# Create export zip archive
zip_file_path = export_dir / f'export-{timestamp}.zip'
with zipfile.ZipFile(zip_file_path, 'w') as export_zip:
export_zip.write(config_dest_path, arcname=const.configFile)
export_zip.write(dump_file, arcname='database.sql')
# Add favicon and emojis folders to the zip archive
favicon_dir = Path('static/icons/favicon')
emojis_dir = Path('static/emojis')
if favicon_dir.exists():
for root, _, files in os.walk(favicon_dir):
for file in files:
file_path = Path(root) / file
export_zip.write(file_path, arcname=file_path.relative_to(favicon_dir.parent.parent))
if emojis_dir.exists():
for root, _, files in os.walk(emojis_dir):
for file in files:
file_path = Path(root) / file
export_zip.write(file_path, arcname=file_path.relative_to(emojis_dir.parent))
# Record export metadata
export_data = {
'timestamp_esc': timestamp,
'timestamp': timestamp_morereadable,
'downloadPath': str(zip_file_path)
}
appendToJSON(export_data, const.exportsFile)
shutil.rmtree(temp_dir)
return {'message': _('Export created successfully!')}
except psycopg.Error as e:
return {'error': str(e)}, 500
except Exception as e:
return {'error': str(e)}, 500
def importData(export_file) -> dict:
try:
shutil.unpack_archive(export_file, const.tempDir)
# Replace config file
os.remove(const.configFile)
shutil.move(const.tempDir / const.configFile, Path.cwd() / const.configFile)
# Replace favicon and emojis folders
favicon_dest = Path('static/icons/favicon')
emojis_dest = Path('static/emojis')
shutil.rmtree(favicon_dest)
shutil.copytree(const.tempDir / 'icons' / 'favicon', favicon_dest)
shutil.rmtree(emojis_dest)
shutil.copytree(const.tempDir / 'emojis', emojis_dest)
# Restore database from SQL file
conn = connectToDb()
cursor = conn.cursor()
dump_file = const.tempDir / 'database.sql'
process = subprocess.Popen(
f'pg_restore --clean -U {dbUser} -d {dbName} {dump_file}',
shell=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
encoding="utf-8",
env=dict(os.environ, PGPASSWORD=dbPass)
)
shutil.rmtree(const.tempDir)
return {'message': _('Data imported successfully!')}
except Exception as e:
return {'error': str(e)}, 500
# will probably get to it in 1.8.0 because my brain can't do it rn
# 2.1.0 maybe -1/12/25
"""
def retrospringImport(export_file):
shutil.unpack_archive(export_file, const.tempDir)
# probably a hack but whateva
export_dirname = Path(export_file).stem
export_dir = const.tempDir / export_dirname
conn = connectToDb()
cursor = conn.cursor()
questions_file = loadJSON(export_dir / 'questions.json')
answers_file = loadJSON(export_dir / 'answers.json')
# Extract answers list
questions_list = questions_file.get('questions', [])
answers_list = answers_file.get('answers', [])
# ['related']['question']['anonymous']
for question in questions_list:
# addQuestion(answer['related']['question']['anonymous'], question['content'], None, noAntispam=True)
for answer in answers_list:
print("anonymous:", answer['related']['question']['anonymous'])
print(question['id'], answer['content'], None)
# addAnswer(question['id'], answer['content'], None)
# shutil.rmtree(const.tempDir)
cursor.close()
conn.close()
"""
def deleteExport(timestamp: str) -> dict:
try:
export_file = Path('static') / 'exports' / f'export-{timestamp}.zip'
data = loadJSON(const.exportsFile)
data = [export for export in data if export["timestamp_esc"] != timestamp]
export_file.unlink()
saveJSON(data, const.exportsFile)
return {'message': _('Export {} deleted successfully.').format(timestamp)}
except Exception as e:
return {'error': str(e)}, 500
# reserved for 1.7.0 or later
"""
def getUserIp():
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
return request.environ['REMOTE_ADDR']
else:
return request.environ['HTTP_X_FORWARDED_FOR']
def isIpBlacklisted(user_ip):
blacklist = readPlainFile(const.ipBlacklistFile, split=True)
return user_ip in blacklist
"""

View file

@ -1,9 +1,9 @@
#!/usr/bin/bash
working_dir=~/.local/share/catask
working_dir=$PWD/catask
bold=$(tput bold)
normal=$(tput sgr0)
git_repo_url="https://git.mst.k.vu/mst/catask"
git_repo_url="https://git.mst.k.vu/catask-org/catask"
git_repo_issue_url="${git_repo_url}/issues/new"
echo "--------------------------"
@ -12,9 +12,9 @@ echo "--------------------------"
echo
echo "${bold}Cloning the repository...${normal}"
# this might work... or not, who knows
id -u catask >/dev/null 2>&1 || sudo useradd -r -s /bin/false -m -d /etc/catask -U catask
# id -u catask >/dev/null 2>&1 || sudo useradd -r -s /bin/false -m -d $working_dir/home/catask -U catask
# cloning dev branch for now because the installer doesn't exist in main branch yet
sudo git clone $git_repo_url $working_dir --branch dev
git clone $git_repo_url $working_dir
cd $working_dir
echo
echo "${bold}Creating & activating virtual environment...${normal}"
@ -64,10 +64,16 @@ echo
echo "${bold}Configuring CatAsk...${normal}"
cp $working_dir/.env.example $working_dir/.env; cp $working_dir/config.example.json $working_dir/config.json
echo
if ! command -v $EDITOR 2>&1 >/dev/null; then
echo "No default editor found, falling back to ${bold}nano${normal}..."
editor_=nano
else
editor_=$EDITOR
fi
read -n 1 -s -r -p "Press any key to open main config file..."
nano config.json
$editor_ config.json
read -n 1 -s -r -p "Press any key to open .env config file..."
nano .env
$editor_ .env
echo
echo "${bold}Initializing the database...${normal}"
@ -89,17 +95,20 @@ if [[ "${sysd_service_input,,}" == 'n' ]]; then
echo "Replace ${bold}127.0.0.1:5000${normal} with address where CatAsk will run"
exit 0
else
read -p "Address on which CatAsk will run (127.0.0.1:5000): " catask_addr
catask_addr=${catask_addr:-127.0.0.1:5000}
sudo cat > /etc/systemd/system/catask.service << EOF
[Unit]
Description=catask
read -p "Address on which CatAsk will run (127.0.0.1:5000): " catask_addr
catask_addr=${catask_addr:-127.0.0.1:5000}
sudo cat > /etc/systemd/system/catask.service << EOF
[Unit]
Description=CatAsk
[Service]
User=catask
WorkingDirectory=$PWD
ExecStart=$PWD/venv/bin/python3 -m gunicorn -w 4 app:app -b $catask_addr
EOF
[Service]
User=%u
WorkingDirectory=$working_dir
ExecStart=$working_dir/venv/bin/python3 -m gunicorn -w 4 app:app -b $catask_addr
[Install]
WantedBy=multi-user.target
EOF
fi
echo "Created a systemd service with these contents:"
echo "-----------------------"

Binary file not shown.

File diff suppressed because it is too large Load diff

1213
messages.pot Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,12 @@
flask
python-dotenv
mysql-connector-python==8.2.0
psycopg[binary,pool]
humanize
mistune
bleach
pathlib
Flask-Compress
gunicorn
Flask-Limiter
pillow
requests
Flask-Babel

View file

@ -1,9 +1,29 @@
# CatAsk stable roadmap
# CatAsk to-do
* [ ] content warnings
* [ ] multiple anti-spam options
* [ ] fediverse verification
* [ ] setting: set custom background image
* [ ] setting: automatic answer crosspost to fediverse
* [x] translation support
* * [x] translating strings with flask-babel
* * [x] changing language based on `Accept-Language` request header
* * [x] setting default language in admin panel
* * [x] setting in admin panel to allow/disallow users changing language themselves
* [x] completed website
* [-] documentation website
* * [x] installation
* * [ ] api docs
* [x] pwa support
* [-] import/export support
* * [x] implement support for importing/exporting catask data
* * [ ] implement support for importing retrospring data
* [x] content warnings
* [x] split admin panel into multiple pages
* [x] multiple anti-spam options
* * [x] basic antispam
* * [x] recaptcha v2
* * [x] more captchas?
* [ ] collapse long questions & answers
* [ ] add custom emojis
* [x] add custom emojis
* [x] move to toastify for alerts
* [x] make stuff more accessible
* [ ] implement private questions

View file

@ -1,21 +1,24 @@
CREATE TABLE IF NOT EXISTS answers (
id INT PRIMARY KEY AUTO_INCREMENT,
question_id INT NOT NULL,
id SERIAL PRIMARY KEY,
question_id INTEGER NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
content TEXT NOT NULL,
cw VARCHAR(255) NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS questions (
id INT PRIMARY KEY AUTO_INCREMENT,
id SERIAL PRIMARY KEY,
from_who VARCHAR(255) NOT NULL,
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
content TEXT NOT NULL,
answered BOOLEAN NOT NULL DEFAULT FALSE,
answer_id INT,
pinned BOOLEAN NOT NULL DEFAULT FALSE
-- below is reserved for version 1.6.0 or later
-- private BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
answer_id INTEGER,
pinned BOOLEAN NOT NULL DEFAULT FALSE,
cw VARCHAR(255) NOT NULL DEFAULT '',
unread BOOLEAN NOT NULL DEFAULT TRUE
-- private BOOLEAN NOT NULL DEFAULT FALSE, -- For later use
-- user_ip BYTEA NOT NULL DEFAULT '' -- For later use
);
ALTER TABLE questions
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;

View file

@ -1,12 +1,4 @@
@font-face {
font-family: "Rubik";
font-display: swap;
font-weight: 100 900;
src: url("../fonts/rubik.woff2") format('woff2-variations');
}
:root {
--bs-font-sans-serif: "Rubik", sans-serif;
--bs-link-color-rgb: var(--bs-primary-rgb);
--bs-nav-link-color: var(--bs-primary);
--bs-border-radius: .5rem;
@ -19,14 +11,14 @@
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
--bs-danger: #dc3545;
--bs-danger-bg-subtle: #f8e6e8;
--bs-link-color: var(--bs-primary);
--bs-link-color: color-mix(in srgb, var(--bs-primary) 70%, var(--bs-body-color));
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, black);
--bs-basic-btn-hover-bg: color-mix(in srgb, var(--bs-body-bg) 95%, 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-strong: color-mix(in srgb, var(--bs-body-bg) 87%, black);
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), black);
--bs-secondary-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 95%, black);
}
[data-bs-theme=dark] {
@ -38,14 +30,22 @@
--bs-danger: #e06672;
--bs-danger-rgb: 224, 102, 114;
--bs-danger-bg-subtle: #2c0b0e;
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, white);
--bs-link-color: color-mix(in srgb, var(--bs-primary), white);
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
--bs-basic-btn-hover-bg:color-mix(in srgb, var(--bs-body-bg) 95%, 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-strong: color-mix(in srgb, var(--bs-body-bg) 87%, white);
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), white);
--bs-secondary-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 95%, white);
}
.bg-body {
background-color: var(--bs-body-bg) !important;
}
.placeholder {
border-radius: var(--bs-border-radius-sm);
}
[data-bs-theme=light] .light-invert,
@ -53,6 +53,21 @@
filter: invert();
}
.tooltip {
--bs-tooltip-color: var(--bs-body-color);
--bs-tooltip-bg: var(--bs-body-bg);
--bs-tooltip-opacity: 1;
}
.tooltip-inner {
border: 1px solid var(--bs-primary-bg-subtle);
box-shadow: var(--bs-box-shadow);
}
.text-primary {
color: var(--bs-primary) !important;
}
.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);
@ -63,9 +78,33 @@
--bs-btn-border-color: var(--bs-basic-btn-active-bg-strong);
}
[data-bs-theme=dark] .btn-secondary {
--bs-btn-color: var(--bs-body-color);
--bs-btn-border-color: var(--bs-basic-btn-hover-bg);
}
[data-bs-theme=light] .btn-secondary {
--bs-btn-color: var(--bs-body-color);
--bs-btn-border-color: var(--bs-basic-btn-hover-bg);
}
.btn-outline-danger {
--bs-btn-color: var(--bs-danger);
--bs-btn-border-color: var(--bs-danger);
--bs-btn-hover-color: ;
--bs-btn-hover-bg: color-mix(in srgb, var(--bs-danger-border-subtle) 60%, transparent);
--bs-btn-hover-border-color: #dc3545;
}
[data-bs-theme=dark] .btn-outline-danger {
--bs-btn-active-bg: var(--bs-danger-border-subtle);
}
[data-bs-theme=dark] .btn-danger {
--bs-btn-border-color: var(--bs-danger-border-subtle);
--bs-btn-bg: var(--bs-danger-border-subtle);
--bs-btn-hover-bg: color-mix(in srgb, var(--bs-danger-border-subtle) 80%, var(--bs-body-bg));
--bs-btn-hover-border-color: color-mix(in srgb, var(--bs-danger-border-subtle) 80%, var(--bs-body-bg));
--bs-btn-active-bg: color-mix(in srgb, var(--bs-danger-border-subtle) 70%, var(--bs-body-bg));
--bs-btn-active-border-color: color-mix(in srgb, var(--bs-danger-border-subtle) 70%, var(--bs-body-bg));
}
.btn {
@ -110,11 +149,29 @@
--bs-btn-active-color: var(--bs-body-color);
}
[data-bs-theme=dark] .btn-secondary {
--bs-btn-bg: var(--bs-basic-btn-hover-bg);
--bs-btn-hover-border-color: var(--bs-basic-btn-hover-bg-strong);
--bs-btn-hover-bg: var(--bs-basic-btn-hover-bg-strong);
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
--bs-btn-hover-color: var(--bs-body-color);
--bs-btn-active-color: var(--bs-body-color);
}
[data-bs-theme=light] .btn-secondary {
--bs-btn-bg: var(--bs-basic-btn-hover-bg);
--bs-btn-hover-border-color: var(--bs-basic-btn-hover-bg-strong);
--bs-btn-hover-bg: var(--bs-basic-btn-hover-bg-strong);
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
--bs-btn-hover-color: var(--bs-body-color);
--bs-btn-active-color: var(--bs-body-color);
}
.btn-basic:hover, .btn-basic:focus {
background-color: var(--bs-basic-btn-hover-bg);
}
.btn-basic:active {
background-color: var(--bs-basic-btn-active-bg);
background-color: var(--bs-basic-btn-active-bg) !important;
}
.card-footer .btn-basic:hover, .card-footer .btn-basic:focus {
background-color: var(--bs-basic-btn-hover-bg-strong) !important;
@ -125,6 +182,16 @@
}
.btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active {
border-color: transparent;
background-color: var(--bs-btn-active-bg) !important;
}
.modal {
--bs-modal-padding: 1.3rem;
--bs-modal-header-padding: 1.3rem;
}
.modal.fade .modal-dialog {
transition: transform .2s ease-out;
}
[data-bs-theme=light] .nav-link, a {
@ -175,8 +242,7 @@ a:hover {
border-radius: var(--bs-border-radius);
}
.dropdown-item {
padding-left: .6em;
padding-right: .6em;
padding: .25em .6em;
}
.bg-hover-danger.dropdown-item:hover {
@ -184,17 +250,21 @@ a:hover {
--bs-dropdown-link-hover-color: var(--bs-danger);
}
.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active {
/*.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;
background-color: var(--bs-danger) !important;
color: var(--bs-dropdown-bg) !important;*/
/* color: white !important; */
/* --bs-dropdown-link-active-color: white; */
/* } */
.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active {
color: var(--bs-dropdown-link-active-color) !important;
}
.form-control:focus, .form-check-input:focus,
.accordion-button:focus, .btn:focus-visible {
.accordion-button:focus, .btn:focus-visible, .form-select: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);
border-color: color-mix(in srgb, var(--bs-primary) 80%, transparent);
outline: 0;
}
@ -207,26 +277,6 @@ a:hover {
box-shadow: var(--bs-box-shadow);
}
.no-arrow.dropdown-toggle::after {
border: none;
display: none;
}
.markdown-content p {
margin: 0;
}
.markdown-content ol {
margin-bottom: 0;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}
.form-check-input:checked {
background-color: var(--bs-primary);
border-color: var(--bs-primary);
@ -282,3 +332,36 @@ a:hover {
.text-bg-primary {
background-color: var(--bs-primary) !important;
}
.emoji {
transition: .2s;
}
.emoji:hover {
scale: 1.5;
}
.scale-parent:hover .scale-child,
.scale-parent.active .scale-child {
scale: 1.2;
}
.scale-parent .scale-child {
transition: .2s;
}
.badge {
--bs-badge-padding-x: 0.5em;
--bs-badge-padding-y: 0.25em;
}
@media screen and (max-width: 800px) {
.dropdown-item {
padding: .4em .6em;
}
}
@media screen and (max-width: 991px) {
.fs-mob-5 {
font-size: 1.25rem !important;
}
}

View file

@ -5,7 +5,7 @@
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
--bs-danger: #dc3545;
--bs-danger-bg-subtle: #f8e6e8;
--bs-link-color: color-mix(in srgb, var(--bs-primary) 75%, black);
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, var(--bs-body-color));
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, black);
--bs-basic-btn-hover-bg: color-mix(in srgb, var(--bs-primary) 10%, var(--bs-body-bg));
--bs-basic-btn-hover-bg-strong: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg));
@ -16,12 +16,12 @@
--bs-dropdown-active-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
--bs-tertiary-bg: var(--bs-primary-bg-subtle);
--bs-secondary-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-body-color));
--bs-secondary-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 95%, black);
}
[data-bs-theme=dark] {
--bs-body-bg-untinted: #202020;
--bs-body-bg: color-mix(in srgb, var(--bs-primary) 5%, var(--bs-body-bg-untinted));
/* --bs-body-bg-rgb: 32, 32, 32;*/
--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) 55%, white);
@ -35,6 +35,11 @@
--bs-dropdown-active-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
--bs-tertiary-bg: var(--bs-primary-bg-subtle);
--bs-secondary-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-body-color));
--bs-secondary-bg: color-mix(in srgb, var(--bs-primary-bg-subtle) 95%, white);
}
.bg-body-tertiary {
background-color: var(--bs-tertiary-bg) !important;
}
.btn-check:checked + .btn-secondary, .btn-check:checked + .btn-outline-secondary {
@ -91,3 +96,8 @@
--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%, black);
}
hr {
border-color: var(--bs-border-color);
opacity: .75;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,237 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="380"
height="380"
viewBox="0 0 380 380"
fill="none"
version="1.1"
id="svg9"
sodipodi:docname="catask-theme-store.svg"
inkscape:version="1.4 (e7c3feb100, 2024-10-09)"
inkscape:export-filename="catask-theme-store.png"
inkscape:export-xdpi="258.69473"
inkscape:export-ydpi="258.69473"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview9"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="0.48026316"
inkscape:cx="660.05479"
inkscape:cy="533.04109"
inkscape:window-width="1920"
inkscape:window-height="1022"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg9"
showgrid="false" /><rect
width="380"
height="380"
rx="70"
fill="url(#paint0_linear_603_9)"
id="rect1"
style="fill-opacity:1;fill:url(#linearGradient17)" /><path
fill-rule="evenodd"
clip-rule="evenodd"
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:#ffffff;fill-opacity:0.55120105" /><mask
id="path-3-inside-1_603_9"
fill="white"><path
fill-rule="evenodd"
clip-rule="evenodd"
d="M158 221C158 234.256 168.744 245 182 245C195.256 245 206 234.256 206 221V210.336C212.376 209.056 219.496 207.168 226.696 204.36C259.784 191.504 278 166.16 278 133C278 92.856 254 45 190 45C146.64 45 110 77.976 110 117C110 130.256 120.744 141 134 141C147.256 141 158 130.256 158 117C158 109 170.472 93 190 93C214 93 230 109 230 125C230 157 186.824 164.952 182 165C168.744 165 158 175.744 158 189V221ZM206 285C206 298.255 195.255 309 182 309C168.745 309 158 298.255 158 285C158 271.745 168.745 261 182 261C195.255 261 206 271.745 206 285Z"
id="path2" /></mask><path
fill-rule="evenodd"
clip-rule="evenodd"
d="m 154,234 c 0,13.256 10.744,24 24,24 13.256,0 24,-10.744 24,-24 v -10.664 c 6.376,-1.28 13.496,-3.168 20.696,-5.976 C 255.784,204.504 274,179.16 274,146 c 0,-40.144 -24,-87.999999 -88,-87.999999 -43.36,0 -80,32.976 -80,71.999999 0,13.256 10.744,24 24,24 13.256,0 24,-10.744 24,-24 0,-8 12.472,-24 32,-24 24,0 40,16 40,32 0,32 -43.176,39.952 -48,40 -13.256,0 -24,10.744 -24,24 z m 48,64 c 0,13.255 -10.745,24 -24,24 -13.255,0 -24,-10.745 -24,-24 0,-13.255 10.745,-24 24,-24 13.255,0 24,10.745 24,24 z"
fill="url(#paint1_linear_603_9)"
id="path3"
style="fill:none" /><path
d="M206 210.336L205.606 208.375L204 208.698V210.336H206ZM226.696 204.36L225.972 202.496L225.969 202.497L226.696 204.36ZM182 165V167H182.01L182.02 167L182 165ZM182 243C169.849 243 160 233.151 160 221H156C156 235.361 167.639 247 182 247V243ZM204 221C204 233.151 194.151 243 182 243V247C196.361 247 208 235.361 208 221H204ZM204 210.336V221H208V210.336H204ZM225.969 202.497C218.9 205.254 211.895 207.113 205.606 208.375L206.394 212.297C212.857 210.999 220.092 209.082 227.423 206.223L225.969 202.497ZM276 133C276 165.261 258.362 189.911 225.972 202.496L227.42 206.224C261.206 193.097 280 167.059 280 133H276ZM190 47C221.445 47 242.863 58.7285 256.457 75.2182C270.103 91.7706 276 113.29 276 133H280C280 112.566 273.897 90.0854 259.543 72.6738C245.137 55.1995 222.555 43 190 43V47ZM112 117C112 79.2736 147.541 47 190 47V43C145.739 43 108 76.6784 108 117H112ZM134 139C121.849 139 112 129.151 112 117H108C108 131.361 119.639 143 134 143V139ZM156 117C156 129.151 146.151 139 134 139V143C148.361 143 160 131.361 160 117H156ZM190 91C179.667 91 171.206 95.2364 165.34 100.513C162.408 103.15 160.098 106.069 158.51 108.896C156.941 111.69 156 114.534 156 117H160C160 115.466 160.618 113.31 161.998 110.854C163.359 108.431 165.387 105.85 168.014 103.487C173.266 98.7636 180.805 95 190 95V91ZM232 125C232 107.617 214.804 91 190 91V95C213.196 95 228 110.383 228 125H232ZM182.02 167C183.477 166.985 187.271 166.429 192.047 165.163C196.878 163.883 202.876 161.836 208.74 158.752C220.422 152.608 232 142.085 232 125H228C228 139.915 217.99 149.368 206.878 155.212C201.345 158.122 195.645 160.072 191.023 161.296C186.347 162.535 182.935 162.991 181.98 163L182.02 167ZM160 189C160 176.849 169.849 167 182 167V163C167.639 163 156 174.639 156 189H160ZM160 221V189H156V221H160ZM182 311C196.359 311 208 299.359 208 285H204C204 297.15 194.15 307 182 307V311ZM156 285C156 299.359 167.641 311 182 311V307C169.85 307 160 297.15 160 285H156ZM182 259C167.641 259 156 270.641 156 285H160C160 272.85 169.85 263 182 263V259ZM208 285C208 270.641 196.359 259 182 259V263C194.15 263 204 272.85 204 285H208Z"
fill="url(#paint2_linear_603_9)"
fill-opacity="0.58"
mask="url(#path-3-inside-1_603_9)"
id="path4"
style="fill:none" /><defs
id="defs9"><linearGradient
id="linearGradient25"
inkscape:collect="always"><stop
style="stop-color:#ffffff;stop-opacity:0.40011275;"
offset="0"
id="stop24" /><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="1"
id="stop25" /></linearGradient><linearGradient
id="linearGradient22"
inkscape:collect="always"><stop
style="stop-color:#ffffff;stop-opacity:0.39976645;"
offset="0"
id="stop21" /><stop
style="stop-color:#ffffff;stop-opacity:1;"
offset="1"
id="stop22" /></linearGradient><linearGradient
id="linearGradient16"
inkscape:collect="always"><stop
style="stop-color:#e000ff;stop-opacity:1;"
offset="0"
id="stop16" /><stop
style="stop-color:#00e3ff;stop-opacity:1;"
offset="1"
id="stop17" /></linearGradient><linearGradient
id="paint0_linear_603_9"
x1="190"
y1="0"
x2="190"
y2="380"
gradientUnits="userSpaceOnUse"><stop
stop-color="#CE95FF"
id="stop4"
offset="0"
style="stop-color:#9a69ce;stop-opacity:1;" /><stop
offset="1"
stop-color="#8B52BC"
id="stop5"
style="stop-color:#6f42c1;stop-opacity:1;" /></linearGradient><linearGradient
id="paint1_linear_603_9"
x1="182"
y1="45"
x2="182"
y2="309"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-3.9999965,13.000001)"><stop
stop-color="#CE5AFF"
id="stop6"
offset="0.2899459"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
offset="1"
stop-color="#C12FFF"
id="stop7"
style="stop-color:#ffffff;stop-opacity:0.54639173;" /></linearGradient><linearGradient
id="paint2_linear_603_9"
x1="194"
y1="45"
x2="194"
y2="309"
gradientUnits="userSpaceOnUse"><stop
stop-color="#B34999"
id="stop8"
offset="0"
style="stop-color:#ffffff;stop-opacity:1;" /><stop
offset="1"
stop-color="#B14CD5"
id="stop9"
style="stop-color:#e7c5f0;stop-opacity:1;" /></linearGradient><radialGradient
id="a"
cx="0"
cy="800"
r="800"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#ff8000"
id="stop1-0" /><stop
offset="1"
stop-color="#ff8000"
stop-opacity="0"
id="stop2-9" /></radialGradient><radialGradient
id="b"
cx="1200"
cy="800"
r="800"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#00ff19"
id="stop3-3" /><stop
offset="1"
stop-color="#00ff19"
stop-opacity="0"
id="stop4-6" /></radialGradient><radialGradient
id="c"
cx="600"
cy="0"
r="600"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#9900ff"
id="stop5-0" /><stop
offset="1"
stop-color="#9900ff"
stop-opacity="0"
id="stop6-6" /></radialGradient><radialGradient
id="d"
cx="600"
cy="800"
r="600"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#ffff00"
id="stop7-2" /><stop
offset="1"
stop-color="#ffff00"
stop-opacity="0"
id="stop8-6" /></radialGradient><radialGradient
id="e"
cx="0"
cy="0"
r="800"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#FF0000"
id="stop9-1" /><stop
offset="1"
stop-color="#FF0000"
stop-opacity="0"
id="stop10-8" /></radialGradient><radialGradient
id="f"
cx="1200"
cy="0"
r="800"
gradientUnits="userSpaceOnUse"><stop
offset="0"
stop-color="#0CF"
id="stop11" /><stop
offset="1"
stop-color="#0CF"
stop-opacity="0"
id="stop12" /></radialGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient16"
id="linearGradient17"
x1="229.50328"
y1="375.84778"
x2="150.49687"
y2="4.151907"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient25"
id="linearGradient23"
x1="8.0187082"
y1="131.98808"
x2="239.99017"
y2="131.98808"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient22"
id="linearGradient24"
gradientUnits="userSpaceOnUse"
x1="8.0187082"
y1="131.98807"
x2="239.99019"
y2="131.98807" /></defs><g
style="fill:url(#linearGradient23)"
id="g19"
transform="matrix(1.0490726,0,0,1.0490726,59.910333,51.534941)"><path
d="m 230.64,25.36 a 32,32 0 0 0 -45.26,0 q -0.21,0.21 -0.42,0.45 L 131.55,88.22 121,77.64 a 24,24 0 0 0 -33.95,0 l -76.69,76.7 a 8,8 0 0 0 0,11.31 l 80,80 a 8,8 0 0 0 11.31,0 L 178.36,169 a 24,24 0 0 0 0,-33.95 L 167.78,124.48 230.19,71 c 0.15,-0.14 0.31,-0.28 0.45,-0.43 a 32,32 0 0 0 0,-45.21 z M 96,228.69 79.32,212 101.66,189.65 A 8,8 0 0 0 90.35,178.34 L 68,200.68 55.32,188 77.66,165.65 A 8,8 0 0 0 66.35,154.34 L 44,176.68 27.31,160 l 50.35,-50.34 68.69,68.69 z"
id="path1-9"
style="fill:url(#linearGradient24)" /></g></svg>

After

Width:  |  Height:  |  Size: 11 KiB

1
static/js/codemirror-css.min.js vendored Normal file

File diff suppressed because one or more lines are too long

2
static/js/emoji-mart.js Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

117
templates/admin/base.html Normal file
View file

@ -0,0 +1,117 @@
{% extends 'base.html' %}
{% block title %}{% block _title %}{% endblock %} - {{ _('Admin') }}{% endblock %}
{% set adminLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/coloris.min.css') }}">
{% block _additionalHeadItems %}{% endblock %}
<style>
[data-bs-theme=dark] .form-switch .form-check-input:focus:not(:checked) {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23{{ cfg.style.accentDark[1:] }}'/%3e%3c/svg%3e");
}
[data-bs-theme=light] .form-switch .form-check-input:focus:not(:checked) {
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23{{ cfg.style.accentLight[1:] }}'/%3e%3c/svg%3e");
}
.btn.btn-outline-secondary:focus {
border-color: transparent;
background-color: var(--bs-btn-active-bg) !important;
}
@media screen and (max-width: 767px) {
#sidebar-col {
border-right: 0 !important;
padding-right: calc(var(--bs-gutter-x) * .5) !important;
}
#sidebar {
padding-top: 0 !important;
}
#content {
padding-left: calc(var(--bs-gutter-x) * .5) !important;
margin-top: 1rem !important;
}
}
</style>
{% endblock %}
{% block content %}
<!-- this is actually not used anymore, but htmx requires a valid target so we have to use it -->
<div id="response-container"></div>
<div class="row">
<div class="col-md-3 col-xxl-2 border-end pe-4" id="sidebar-col">
<div id="sidebar" class="sticky-lg-top pt-2">
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ info_link }} d-flex align-items-center" href="{{ url_for('admin.information') }}">
<i class="bi bi-card-text fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Information') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ access_link }} d-flex align-items-center" href="{{ url_for('admin.accessibility') }}">
<i class="bi bi-universal-access fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Accessibility') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ langs_link }} d-flex align-items-center" href="{{ url_for('admin.languages') }}">
<i class="bi bi-translate fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Languages') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ notif_link }} d-flex align-items-center" href="{{ url_for('admin.notifications') }}">
<i class="bi bi-bell fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Notifications') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ custom_link }} d-flex align-items-center" href="{{ url_for('admin.customize') }}">
<i class="bi bi-columns-gap fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Customize') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ antispam_link }} d-flex align-items-center" href="{{ url_for('admin.antispam') }}">
<i class="bi bi-shield fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Anti-spam') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ general_link }} d-flex align-items-center" href="{{ url_for('admin.general') }}">
<i class="bi bi-gear fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('General') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ emojis_link }} d-flex align-items-center" href="{{ url_for('admin.emojis') }}">
<i class="bi bi-emoji-smile fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Emojis') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ import_link }} d-flex align-items-center" href="{{ url_for('admin.importExport') }}">
<i class="bi bi-box-seam fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Import/Export') }}</span>
</a>
<a class="ps-3 btn btn-outline-secondary my-1 fs-6 scale-parent {{ blacklist_link }} d-flex align-items-center" href="{{ url_for('admin.blacklist') }}">
<i class="bi bi-ban fs-5 scale-child"></i>
<span class="sidebar-btn-text ms-2 ps-1">{{ _('Word blacklist') }}</span>
</a>
<hr class="d-block d-md-none mt-4">
</div>
</div>
<div class="col-md-9 col-xxl-10 ps-4" id="content">
{% block _content %}{% endblock %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script>
document.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response;
if (jsonResponse && event.detail.target.dataset.dontshowtoast != '') {
const parsed = JSON.parse(jsonResponse);
const alertType = event.detail.successful ? 'success' : 'danger';
message = event.detail.successful ? parsed.message : parsed.error;
if (event.detail.target.id != "question-count" && event.detail.target.dataset.dontshowtoast != '') {
if (event.detail.target.className.includes("emoji-")) {
event.detail.target.remove();
}
Toastify({
text: message,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-${alertType} shadow alert-dismissible`,
close: true
}).showToast();
}
}
});
</script>
{% block _scripts %}
{% endblock %}
{% endblock %}

View file

@ -0,0 +1,49 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Accessibility') }}{% endblock %}
{% set access_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="general" class="mb-2 fw-normal">{{ _('Accessibility') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Make {} accessible to everyone').format(const.appName) }}</p>
<div class="form-group mb-4">
<label class="form-label" for="accessibility.font">{{ _('Font') }}</label>
<select id="accessibility.font" name="accessibility.font" class="form-select">
<option value="default"{% if cfg.accessibility.font == 'default' %} selected{% endif %}>{{ _('Default') }}</option>
<option value="system"{% if cfg.accessibility.font == 'system' %} selected{% endif %}>{{ _('System') }}</option>
<option value="atkinson"{% if cfg.accessibility.font == 'atkinson' %} selected{% endif %}>{{ _('Atkinson Hyperlegible') }}</option>
</select>
</div>
{#- i dont think userway should be translated since its a company name and those usually arent translated #}
<h3 id="antispam" class="mb-2 fw-normal d-flex align-items-center gap-2">UserWay <a href="https://userway.org/" target="_blank" class="fs-5" title="{{ _("what's this?") }}"><i class="bi bi-question-circle"></i></a></h3>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_accessibility.userway.enabled"
id="_accessibility.userway.enabled"
value="{{ cfg.accessibility.userway.enabled }}"
role="switch"
{% if cfg.accessibility.userway.enabled %}checked{% endif %}>
<input type="hidden" id="accessibility.userway.enabled" name="accessibility.userway.enabled" value="{{ cfg.accessibility.userway.enabled }}">
<label for="_accessibility.userway.enabled" class="form-check-label">{{ _('Enabled') }}</label>
</div>
<div class="form-group mb-3">
<label class="form-label" for="accessibility.userway.account">{{ _('Account key') }}</label>
<input type="text" id="accessibility.userway.account" name="accessibility.userway.account" value="{{ cfg.accessibility.userway.account }}" class="form-control">
<p class="form-text">
{{ _('UserWay account key, find one at') }} <a href="https://manage.userway.org/embed-code" target="_blank">manage.userway.org/embed-code</a>
</p>
</div>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,117 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Anti-spam') }}{% endblock %}
{% set antispam_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="antispam" class="mb-2 fw-normal">{{ _('Anti-spam') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Protect your {} instance from spammers').format(const.appName) }}</p>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_antispam.enabled"
id="_antispam.enabled"
value="{{ cfg.antispam.enabled }}"
role="switch"
{% if cfg.antispam.enabled %}checked{% endif %}>
<input type="hidden" id="antispam.enabled" name="antispam.enabled" value="{{ cfg.antispam.enabled }}">
<label for="_antispam.enabled" class="form-check-label">{{ _('Enabled') }}</label>
</div>
{% if not cfg.antispam.enabled %}
<div class="alert alert-warning border-0 small" role="alert">
<p class="m-0 d-flex align-items-center gap-2 fw-medium"><i class="bi bi-exclamation-circle fs-5"></i> {{ _('Warning') }}</p>
<p class="m-0">
{{ _("It's highly encouraged to keep anti-spam enabled, otherwise you could become a target of a spam attack.") }}<br><br>
{#- i hate quotes and their issues #}
{{ _("If you don't want your visitors to worry about completing a CAPTCHA, some providers have 'non-interactive' and 'invisible' CAPTCHA variants.") }}
</p>
</div>
{% endif %}
<div class="form-group mb-3{% if not cfg.antispam.enabled %}d-none{% endif %}">
<label class="form-label" for="antispam.type">{{ _('Anti-spam type') }}</label>
<select id="antispam.type" name="antispam.type" class="form-select" onchange="checkForRecaptcha(this)">
<option value="basic"{% if cfg.antispam.type == 'basic' %} selected{% endif %}>{{ _('Basic') }}</option>
<option value="turnstile"{% if cfg.antispam.type == 'turnstile' %} selected{% endif %}>Cloudflare Turnstile</option>
<option value="frc"{% if cfg.antispam.type == 'frc' %} selected{% endif %}>Friendly Captcha</option>
<option value="recaptcha"{% if cfg.antispam.type == 'recaptcha' %} selected{% endif %}>reCAPTCHA v2</option>
</select>
<p class="form-text">{{ _('Anti-spam type to use') }}</p>
</div>
<div id="recaptcha-options"{% if cfg.antispam.type != 'recaptcha' %} class="d-none"{% endif %}>
<h3 id="recaptcha" class="mb-3 fw-light">{{ _('reCAPTCHA options') }}</h3>
<div class="form-group mb-3">
<label class="form-label" for="antispam.recaptcha.sitekey">{{ _('Site key') }}</label>
<input type="text" id="antispam.recaptcha.sitekey" name="antispam.recaptcha.sitekey" value="{{ cfg.antispam.recaptcha.sitekey }}" class="form-control">
<p class="form-text">{{ _('reCAPTCHA site key') }}</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="antispam.recaptcha.secretkey">{{ _('Secret key') }}</label>
<input type="password" id="antispam.recaptcha.secretkey" name="antispam.recaptcha.secretkey" value="{{ cfg.antispam.recaptcha.secretkey }}" class="form-control">
<p class="form-text">{{ _('reCAPTCHA secret key') }}</p>
</div>
</div>
<div id="turnstile-options"{% if cfg.antispam.type != 'turnstile' %} class="d-none"{% endif %}>
<h3 id="turnstile" class="mb-3 fw-light">{{ _('Turnstile options') }}</h3>
<div class="form-group mb-3">
<label class="form-label" for="antispam.turnstile.sitekey">{{ _('Site key') }}</label>
<input type="text" id="antispam.turnstile.sitekey" name="antispam.turnstile.sitekey" value="{{ cfg.antispam.turnstile.sitekey }}" class="form-control">
<p class="form-text">{{ _('Turnstile site key') }}</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="antispam.turnstile.secretkey">{{ _('Secret key') }}</label>
<input type="password" id="antispam.turnstile.secretkey" name="antispam.turnstile.secretkey" value="{{ cfg.antispam.turnstile.secretkey }}" class="form-control">
<p class="form-text">{{ _('Turnstile secret key') }}</p>
</div>
</div>
<div id="frc-options"{% if cfg.antispam.type != 'frc' %} class="d-none"{% endif %}>
<h3 id="frc" class="mb-3 fw-light">{{ _('Friendly Captcha options') }}</h3>
<div class="form-group mb-3">
<label class="form-label" for="antispam.frc.sitekey">{{ _('Site key') }}</label>
<input type="text" id="antispam.frc.sitekey" name="antispam.frc.sitekey" value="{{ cfg.antispam.frc.sitekey }}" class="form-control">
<p class="form-text">{{ _('Friendly Captcha site key') }}</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="antispam.frc.apikey">{{ _('API key') }}</label>
<input type="password" id="antispam.frc.apikey" name="antispam.frc.apikey" value="{{ cfg.antispam.frc.apikey }}" class="form-control">
<p class="form-text">{{ _('Friendly Captcha API key') }}</p>
</div>
</div>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script>
let recaptchaDiv = document.getElementById("recaptcha-options");
let turnstileDiv = document.getElementById("turnstile-options");
let frcDiv = document.getElementById("frc-options");
const typeSelect = document.getElementById('antispam.type');
function checkForRecaptcha(element) {
console.log(element);
if (element.value === 'recaptcha') {
recaptchaDiv.classList.remove('d-none');
turnstileDiv.classList.add('d-none');
frcDiv.classList.add('d-none');
} else if (element.value === 'turnstile') {
turnstileDiv.classList.remove('d-none');
recaptchaDiv.classList.add('d-none');
frcDiv.classList.add('d-none');
} else if (element.value === 'frc') {
turnstileDiv.classList.add('d-none');
recaptchaDiv.classList.add('d-none');
frcDiv.classList.remove('d-none');
} else {
recaptchaDiv.classList.add('d-none');
turnstileDiv.classList.add('d-none');
frcDiv.classList.add('d-none');
}
}
</script>
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Word blacklist') }}{% endblock %}
{% set blacklist_link = 'active' %}
{% block _content %}
<h2 id="blacklist" class="fw-normal mb-2">{{ _('Word blacklist') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _("Blacklist words that you don't want to see") }}</p>
<form hx-put="{{ url_for('api.updateBlacklist') }}" 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">{{ _('Blacklisted words for questions, one word per line') }}</label>
<!-- <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>
{% include 'snippets/admin/saveBtn.html' %}
</div>
</form>
{% endblock %}

View file

@ -0,0 +1,498 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Customize') }}{% endblock %}
{% set custom_link = 'active' %}
{% block _additionalHeadItems %}
<style>
.cm-content, .cm-gutter {
min-height: 200px !important;
}
.cm-editor, .cm-scroller {
border-radius: var(--bs-border-radius);
}
.cm-editor {
font-size: 14px;
}
.ͼ1.cm-focused {
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent);
outline: 0 !important;
}
#settings-dropdown {
min-width: 25rem;
}
@media screen and (max-width: 600px) {
#settings-dropdown {
min-width: 100vw;
}
}
</style>
{% endblock %}
{% block _content %}
<h2 id="customize" class="mb-2 fw-normal">{{ _('Customize') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Customize {} to your liking').format(const.appName) }}</p>
<h3 class="fw-light">{{ _('Favicon') }}</h3>
<form hx-post="{{ url_for('api.uploadFavicon') }}" hx-target="#response-container" hx-swap="none" hx-encoding="multipart/form-data">
<p class="m-0">{{ _('Current favicon:') }} <img src="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}" width="32" height="32" alt="{{ cfg.instance.title }}'s icon" class="rounded"></p>
<div class="mt-2">
<label for="favicon" class="form-label">{{ _('Upload favicon') }}</label>
<input class="form-control" type="file" id="favicon" name="favicon">
</div>
<div class="mb-4">
<button type="submit" class="btn btn-primary mt-3" id="saveFavicon">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-file-earmark-arrow-up me-1"></i> {{ _('Upload') }}
</button>
</div>
</form>
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none" hx-on::before-request="setCmTextareaValue()">
<h3 class="fw-light">{{ _('Accent color') }}</h3>
{# <h4 class="fw-light">Color</h4> #}
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.accentLight">{{ _('Light theme') }}</label>
<input type="text" name="style.accentLight" id="style.accentLight" value="{{ cfg.style.accentLight }}" class="form-control" data-coloris title="{{ _('Click to open the color picker') }}">
<p class="form-text">{{ _('Default') }}: <b>#6345d9</b></p>
</div>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.accentDark">{{ _('Dark theme') }}</label>
<input type="text" name="style.accentDark" id="style.accentDark" value="{{ cfg.style.accentDark }}" class="form-control" data-coloris title="{{ _('Click to open the color picker') }}">
<p class="form-text">{{ _('Default') }}: <b>#7a63e3</b></p>
</div>
{# brain doesn't feel like implementing this rn (9/27/24)
<h4 class="fw-light mt-2">Background</h4>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.bgLight">Light theme</label>
<input type="text" name="style.bgLight" id="style.bgLight" value="{{ cfg.style.bgLight }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#ffffff</b></p>
</div>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.bgDark">Dark theme</label>
<input type="text" name="style.bgDark" id="style.bgDark" value="{{ cfg.style.bgDark }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#202020</b></p>
</div>
#}
<div class="form-check form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
name="_style.tintColors"
id="_style.tintColors"
value="{{ cfg.style.tintColors }}"
role="switch"
{% if cfg.style.tintColors == true %}checked{% endif %}>
<input type="hidden" id="style.tintColors" name="style.tintColors" value="{{ cfg.style.tintColors }}">
<label for="_style.tintColors" class="form-check-label">{{ _('Tint all colors with accent color') }}</label>
</div>
<div class="form-check form-switch mb-1">
<input
class="form-check-input nav-icons-checkbox"
type="checkbox"
name="_style.navIcons"
id="_style.navIcons"
value="{{ cfg.style.navIcons }}"
role="switch"
{% if cfg.style.navIcons == true %}checked{% endif %}>
<input type="hidden" id="style.navIcons" name="style.navIcons" value="{{ cfg.style.navIcons }}">
<label for="_style.navIcons" class="form-check-label">{{ _('Include icons in nav links') }}</label>
</div>
<p class="form-text mb-2"><b>{{ _('Note') }}:</b> {{ _('on mobile screens text will always be hidden if this option is enabled') }}</p>
<div class="form-check form-switch mb-4 ms-3">
<input
class="form-check-input nav-icons-only-checkbox"
type="checkbox"
name="_style.navIconsOnly"
id="_style.navIconsOnly"
value="{{ cfg.style.navIconsOnly }}"
role="switch"
{% if cfg.style.navIconsOnly == true %}checked{% endif %}>
<input type="hidden" id="style.navIconsOnly" name="style.navIconsOnly" value="{{ cfg.style.navIconsOnly }}">
<label for="_style.navIconsOnly" class="form-check-label">{{ _('Include only icons in nav links') }}</label>
</div>
{#
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_style.tintBackground"
id="_style.tintBackground"
value="{{ cfg.style.tintBackground }}"
role="switch"
{% if cfg.style.tintBackground == true %}checked{% endif %}>
<input type="hidden" id="style.tintBackground" name="style.tintBackground" value="{{ cfg.style.tintBackground }}">
<label for="_style.tintBackground" class="form-check-label">Tint background with accent color</label>
</div>
#}
<h3 class="fw-light">{{ _('Question card layout') }}</h3>
<div class="btn-group mb-4 w-100" role="group" aria-label="{{ _('Navbar link style group') }}">
<input class="btn-check" type="radio" name="style.cardStyle" id="style.cardStyle.default" value="default" {% if cfg.style.cardStyle == 'default' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.cardStyle.default">
{{ _('Default') }}
</label>
<input class="btn-check" type="radio" name="style.cardStyle" id="style.cardStyle.compact" value="compact" {% if cfg.style.cardStyle == 'compact' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.cardStyle.compact">
{{ _('Compact') }}
</label>
</div>
<h3 class="fw-light">{{ _('Navbar link style') }}</h3>
<div class="btn-group mb-4 w-100" role="group" aria-label="{{ _('Navbar link style group') }}">
<input class="btn-check" type="radio" name="style.navStyle" id="style.navStyle.underline" value="underline" {% if cfg.style.navStyle == 'underline' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.navStyle.underline">
{{ _('Underline') }}
</label>
<input class="btn-check" type="radio" name="style.navStyle" id="style.navStyle.pills" value="pills" {% if cfg.style.navStyle == 'pills' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.navStyle.pills">
{{ _('Pills') }}
</label>
</div>
<h3 class="fw-light">{{ _('Homepage layout') }}</h3>
<div class="btn-group mb-4 w-100" role="group" aria-label="{{ _('Homepage layout group') }}">
<input class="btn-check" type="radio" name="style.homepageLayout" id="style.homepageLayout.catask" value="catask" {% if cfg.style.homepageLayout == 'catask' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.homepageLayout.catask">
CatAsk
</label>
<input class="btn-check" type="radio" name="style.homepageLayout" id="style.homepageLayout.retrospring" value="retrospring" {% if cfg.style.homepageLayout == 'retrospring' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.homepageLayout.retrospring">
Retrospring
</label>
</div>
<h3 class="fw-light">{{ _('Info box layout') }}</h3>
<div class="btn-group mb-4 w-100" role="group" aria-label="Info box layout group">
<input class="btn-check" type="radio" name="style.infoBoxLayout" id="style.infoBoxLayout.column" value="column" {% if cfg.style.infoBoxLayout == 'column' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.infoBoxLayout.column">
{{ _('Column') }}
</label>
<input class="btn-check" type="radio" name="style.infoBoxLayout" id="style.infoBoxLayout.row" value="row" {% if cfg.style.infoBoxLayout == 'row' %}checked{% endif %}>
<label class="btn btn-outline-secondary w-50" for="style.infoBoxLayout.row">
{{ _('Row') }}
</label>
</div>
<h3 class="fw-light">{{ _('Trimmed content') }}</h3>
<div class="form-group mb-3">
<label class="form-label" for="trimContentAfter">{{ _('Trim content after (characters)') }}</label>
<input type="number" id="trimContentAfter" name="trimContentAfter" value="{{ cfg.trimContentAfter }}" class="form-control">
<p class="form-text">{{ _('Maximum length of content before it gets trimmed (used in sharing options); set to 0 to disable') }}</p>
</div>
<h3 class="fw-light">{{ _('Advanced') }}</h3>
<h4 class="fw-light">{{ _('Custom CSS') }}</h4>
<button class="btn btn-secondary mb-3" id="theme-store-open-btn" type="button" data-bs-toggle="modal" data-bs-target="#theme-store-modal"><i class="bi bi-palette me-1"></i> {{ _("Open Theme Store") }}</button>
<div id="theme-store-modals" hx-get="{{ url_for('api.themeStore_themeModals') }}" data-dontshowtoast hx-target="this" hx-swap="innerHTML" hx-trigger="click from:#theme-store-open-btn">
<!-- htmx will replace this message with theme modals -->
</div>
<div class="modal fade" id="theme-store-modal" tabindex="-1" aria-labelledby="ts-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable modal-fullscreen-md-down modal-xl modal-dialog-centered">
<div class="modal-content">
<div class="modal-header justify-content-between border-0">
<h1 class="d-flex align-items-center gap-2 modal-title fs-5 fw-normal" id="q-modal-label"><img src="{{ url_for('static', filename='icons/catask-theme-store.svg') }}" width="32" height="32" alt="CatAsk Theme Store icon"> {{ _('Theme Store') }}</h1>
<div class="d-flex align-items-center">
<div class="dropdown me-2">
<button class="btn btn-basic px-3 btn-sm fs-5 dropdown-toggle no-arrow" type="button" data-bs-toggle="dropdown" data-bs-auto-close="false" aria-expanded="false">
<i class="bi bi-gear"></i>
<span class="visually-hidden">{{ _('Settings') }}</span>
</button>
<div class="dropdown-menu dropdown-menu-end p-3" id="settings-dropdown">
<h5 class="fw-light mb-3">{{ _('Settings') }}</h5>
<label for="themeStoreUrl" class="form-label">{{ _('Theme Store URL') }}</label>
<div class="input-group mb-3">
<input type="text" id="themeStoreUrl" name="themeStoreUrl" class="form-control" placeholder="{{ cfg.themeStoreUrl }}" value="{{ cfg.themeStoreUrl }}" aria-label="{{ _('Theme Store URL') }}">
<button type="button" class="btn btn-primary" hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none" hx-on::before-request="setCmTextareaValue()" hx-indicator="#spinner-2">
<span class="spinner-border spinner-border-sm htmx-indicator" id="spinner-2" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
{{ _('Save') }}
</button>
</div>
<p class="mb-0">{{ _('Refresh the page to load themes from new instance') }}</p>
</div>
</div>
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-dismiss="modal" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
</div>
<div class="modal-body py-0">
<div hx-get="{{ url_for('api.themeStore_themes') }}" hx-trigger="intersect once" hx-indicator="#ts-indicator" data-dontshowtoast hx-target="this" hx-swap="innerHTML" id="modal_loader">
<div class="d-flex justify-content-center my-5" id="ts-indicator">
<div class="spinner-border" role="status">
<span class="visually-hidden">{{ _('Loading...') }}</span>
</div>
</div>
</div>
</div>
<!-- <div class="modal-footer pt-1 flex-row align-items-stretch w-100 border-0">
<button type="button" class="btn btn-outline-secondary flex-fill flex-lg-grow-0" data-bs-dismiss="modal">Close</button>
</div>-->
</div>
</div>
</div>
<div class="form-check form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
name="_style.useCustomCss"
id="_style.useCustomCss"
value="{{ cfg.style.useCustomCss }}"
role="switch"
{% if cfg.style.useCustomCss %}checked{% endif %}>
<input type="hidden" id="style.useCustomCss" name="style.useCustomCss" value="{{ cfg.style.useCustomCss }}">
<label for="_style.useCustomCss" class="form-check-label">{{ _("Use custom CSS") }}</label>
</div>
<div class="form-check form-check-inline mb-2">
<input
class="form-check-input"
type="checkbox"
name="_style.overrideBaseStyles"
id="_style.overrideBaseStyles"
value="{{ cfg.style.overrideBaseStyles }}"
role="switch"
{% if cfg.style.overrideBaseStyles %}checked{% endif %}>
<input type="hidden" id="style.overrideBaseStyles" name="style.overrideBaseStyles" value="{{ cfg.style.overrideBaseStyles }}">
<label for="_style.overrideBaseStyles" class="form-check-label">{{ _("Override base styles") }}</label>
</div>
<div class="form-check form-check-inline mb-2">
<input
class="form-check-input"
type="checkbox"
name="_style.overrideCatAskStyles"
id="_style.overrideCatAskStyles"
value="{{ cfg.style.overrideCatAskStyles }}"
role="switch"
{% if cfg.style.overrideCatAskStyles %}checked{% endif %}>
<input type="hidden" id="style.overrideCatAskStyles" name="style.overrideCatAskStyles" value="{{ cfg.style.overrideCatAskStyles }}">
<label for="_style.overrideCatAskStyles" class="form-check-label">{{ _("Override {} styles").format(const.appName) }}</label>
</div>
<div class="d-flex flex-column flex-sm-row gap-2 my-2 justify-content-sm-between">
<div>
<button class="btn btn-sm btn-secondary square-btn" type="button" onclick="clearCmText(true)"><i class="bi bi-eraser me-1"></i> <span>{{ _('Clear') }}</span></button>
</div>
<div>
<div class="btn-group" role="group">
<label id="import" for="css-file" class="btn btn-sm btn-secondary"><i class="bi bi-file-earmark-arrow-up me-1"></i> {{ _('Import...') }}</label>
<label id="append" for="css-file" class="btn btn-sm btn-secondary"><i class="bi bi-file-earmark-arrow-up me-1"></i> {{ _('Append...') }}</label>
</div>
<input class="d-none" type="file" id="css-file" name="css-file" accept=".css,text/css">
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#theme-export-modal"><i class="bi bi-filetype-css me-1"></i> {{ _('Export...') }}</button>
</div>
</div>
<div class="modal fade" id="theme-export-modal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header justify-content-between border-0">
<h1 class="modal-title fs-5 fw-normal" id="q-modal-label">{{ _('Export CSS') }}</h1>
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-dismiss="modal" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body py-0">
<div class="mb-3">
<label for="cssFilename" class="form-label">{{ _('Filename') }}</label>
<div class="input-group">
<input type="text" id="cssFilename" class="form-control" placeholder="catask_theme" aria-label="Filename">
<span class="input-group-text">.css</span>
</div>
</div>
</div>
<div class="modal-footer pt-1 flex-row align-items-stretch w-100 border-0">
<button type="button" class="btn btn-primary" onclick="exportCSS()">
<i class="bi bi-download me-1"></i> {{ _('Export') }}
</button>
</div>
</div>
</div>
</div>
<!-- <p class="form-text mb-1">Use <code>Escape</code> and then <code>Tab</code> or <code>Shift + Tab</code> to move out of the editor</p> -->
<div id="codemirror-editor"></div>
<textarea class="d-none" id="style.customCss" name="style.customCss">{{ cfg.style.customCss | safe }}</textarea>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script src="{{ url_for('static', filename='js/codemirror-css.min.js') }}"></script>
<script>
let cmTextarea = document.getElementById("style.customCss");
function exportCSS() {
const cssContent = cmTextarea.value;
const filenameInput = document.getElementById("cssFilename").value.trim();
const filename = filenameInput ? filenameInput + ".css" : "catask_theme.css";
const blob = new Blob([cssContent], { type: "text/css" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
a.class = "d-none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function saveConfig() {
document.getElementById('saveConfig').click();
};
document.body.addEventListener("htmx:beforeRequest", function(evt) {
console.log("HTMX is making a request to:", evt.detail.pathInfo.finalRequestPath);
});
document.body.addEventListener("htmx:afterSwap", function(evt) {
console.log("HTMX swap completed on:", evt.detail.target);
});
function showToast(message, type, gravity = "top", position = "right") {
Toastify({
text: message,
duration: 3000,
gravity: gravity,
position: position,
stopOnFocus: true,
className: `alert alert-${type} shadow alert-dismissible`,
close: true
}).showToast();
}
function clearCmText(toast = false) {
cmTextarea.value = "";
view.update([view.state.update({changes: {from: 0, to: view.state.doc.length, insert: ""}})]);
if (toast) {
showToast("{{ _('Cleared') }}", "success", 'bottom');
}
}
const fileSelector = document.getElementById('css-file');
const importBtn = document.getElementById('import');
const appendBtn = document.getElementById('append');
let replaceCmValue = true;
importBtn.addEventListener('click', () => {
replaceCmValue = true;
});
appendBtn.addEventListener('click', () => {
replaceCmValue = false;
});
fileSelector.addEventListener('change', (event) => readFile(event, replaceCmValue));
function readFile(event, replaceCmValue) {
const file = event.target.files[0];
if (!file || file.type !== "text/css") {
showToast("{{ _('Invalid file type. Only .css files are allowed.') }}", "danger");
event.target.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
const newContent = reader.result;
if (replaceCmValue) {
view.update([view.state.update({changes: {from: 0, to: view.state.doc.length, insert: newContent}})]);
} else {
view.update([view.state.update({changes: {from: view.state.doc.length, to: view.state.doc.length, insert: "\n" + newContent}})]);
}
};
reader.onerror = () => {
console.error("Error reading the file. Please try again.");
};
reader.readAsText(file);
saveConfig();
}
function useTheme(theme_id, theme_name) {
cmTextarea.value = document.getElementById(`theme-source-code-${theme_id}`).innerHTML;
// codemirror 6 at its finest
view.update([view.state.update({changes: {from: 0, to: view.state.doc.length, insert: document.getElementById(`theme-source-code-${theme_id}`).innerHTML}})]);
Toastify({
text: `{{ _("Theme") }} "${theme_name}" {{ _("applied! Enable the \"Use custom CSS\" checkbox if it's off and reload the page to see it.") }}`,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-success shadow alert-dismissible`,
close: true
}).showToast();
saveConfig();
}
const initialState = cm6.createEditorState(`{{ cfg.style.customCss | safe }}`, cmTextarea);
const view = cm6.createEditorView(initialState, document.getElementById("codemirror-editor"));
async function setCmTextareaValue() {
await new Promise(resolve => setTimeout(resolve, 1000));
setCmTextareaValue2();
}
function setCmTextareaValue2() {
console.log(`cm value: ${view.state.doc.toString()}`);
cmTextarea.value = view.state.doc.toString();
}
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();
};
</script>
<script>
document.addEventListener("DOMContentLoaded", function () {
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
// Get the first checkbox and the second checkbox
const navIconsCheckbox = document.querySelector(".nav-icons-checkbox");
const navIconsOnlyCheckbox = document.querySelector(".nav-icons-only-checkbox");
// Define a function to update the second checkbox
function updateNavIconsOnlyCheckbox() {
if (!navIconsCheckbox.checked) {
// Disable the second checkbox and uncheck it
navIconsOnlyCheckbox.checked = false;
navIconsOnlyCheckbox.disabled = true;
// Update the hidden input for the second checkbox
navIconsOnlyCheckbox.nextElementSibling.value = "False";
} else {
// Enable the second checkbox
navIconsOnlyCheckbox.disabled = false;
}
}
// Perform the check on page load
updateNavIconsOnlyCheckbox();
// Add an event listener to the first checkbox to handle changes
navIconsCheckbox.addEventListener("change", updateNavIconsOnlyCheckbox);
});
</script>
<script src="{{ url_for('static', filename='js/coloris.min.js') }}"></script>
<script>
Coloris({
theme: 'square',
themeMode: 'auto',
formatToggle: true,
alpha: false,
swatches: [
'#c70f0f', // Red
'#db5d0e', // Orange
'#968829', // Yellow
'#217d1a', // Green
'#28b59b', // Turquoise
'#338FFF', // Blue
'#3358ff',
'#7a63e3', // Indigo
'#A833FF', // Purple
'#d1298b' // Pink
],
});
</script>
{% endblock %}

View file

@ -0,0 +1,88 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Emojis') }}{% endblock %}
{% set emojis_link = 'active' %}
{% block _content %}
<h2 id="customEmojis" class="mb-2 fw-normal">{{ _('Custom emojis') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Add custom emojis to your {} instance').format(const.appName) }}</p>
<h3 class="fw-light mb-3">{{ _('Upload') }}</h3>
<form hx-post="{{ url_for('api.uploadEmoji') }}" hx-target="#response-container" hx-swap="none" hx-encoding="multipart/form-data">
<div>
<label for="emoji" class="form-label">{{ _('Upload an emoji') }}</label>
<input class="form-control" type="file" id="emoji" name="emoji">
</div>
<div class="mb-4">
<button type="submit" class="btn btn-primary mt-3" id="uploadEmoji">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-file-earmark-plus me-1"></i> {{ _('Upload emoji') }}
</button>
</div>
</form>
<hr>
<form id="pack-form" hx-post="{{ url_for('api.uploadEmojiPack') }}" hx-target="#response-container" hx-swap="none" hx-encoding="multipart/form-data">
<div>
<label for="emoji_archive" class="form-label">{{ _('Upload emoji pack') }}</label>
<input class="form-control" type="file" id="emoji_archive" name="emoji_archive">
<p class="form-text mb-0">{{ _('Supported archive formats:') }} <strong>.zip, .tar, .tar.gz, .tar.bz2, .tar.xz</strong></p>
</div>
<div class="mb-5">
<button type="submit" class="btn btn-primary mt-3" id="uploadEmojiPack">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-file-earmark-zip me-1"></i> {{ _('Upload pack') }}
</button>
</div>
</form>
<h3 class="fw-light mb-3">{{ _('Manage') }}</h3>
<h4 class="fw-light">{{ _('Emojis') }}</h4>
{% if emojis %}
<div class="table-responsive">
<table id="emojiTable" class="table align-middle">
<thead>
<tr>
<th>{{ _('Image') }}</th>
<th>{{ _('Name') }}</th>
<th>{{ _('Filename') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody id="emojis">
{% for emoji in emojis %}
{% include 'snippets/admin/emojiRow.html' %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center">{{ _('No emojis uploaded') }}</p>
{% endif %}
<h4 class="fw-light mt-3">{{ _('Emoji packs') }}</h4>
<p class="text-body-secondary small">{{ _('Please note that if meta.json is not found in the archive, preview images are selected by first emoji name to appear alphabetically, so they may not be accurate sometimes') }}</p>
{% if packs %}
<div class="table-responsive">
<table id="emojiPackTable" class="table align-middle">
<thead>
<tr>
<th>{{ _('Image') }}</th>
<th>{{ _('Name') }}</th>
{% if json_pack %}
<th>{{ _('Author') }}</th>
<th>{{ _('Released') }}</th>
{% endif %}
<th>{{ _('Folder') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody id="packs">
{% for pack in packs %}
{% with json_pack = json_pack, index = loop.index - 1 %}
{% include 'snippets/admin/packRow.html' %}
{% endwith %}
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center">{{ _('No emoji packs uploaded') }}</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,83 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('General') }}{% endblock %}
{% set general_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="general" class="mb-2 fw-normal">{{ _('General') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('General settings') }}</p>
<div class="form-group mb-3">
<label class="form-label" for="username">{{ _('Username (optional)') }}</label>
<input type="text" id="username" name="username" value="{{ cfg.username }}" class="form-control">
<p class="form-text">{{ _('Your username') }}</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="charLimit">{{ _('Question character limit') }}</label>
<input type="number" id="charLimit" name="charLimit" value="{{ cfg.charLimit }}" class="form-control">
<p class="form-text">{{ _('Max length of a question in characters; questions extending this character limit will not be added to the database') }}</p>
</div>
<div class="form-group mb-4">
<label class="form-label" for="anonName">{{ _('Name for anonymous users') }}</label>
<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>
<div class="form-check form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
name="_lockInbox"
id="_lockInbox"
value="{{ cfg.lockInbox }}"
role="switch"
{% if cfg.lockInbox %}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 form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
name="_allowAnonQuestions"
id="_allowAnonQuestions"
value="{{ cfg.allowAnonQuestions }}"
role="switch"
{% if cfg.allowAnonQuestions %}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 form-switch mb-2">
<input
class="form-check-input"
type="checkbox"
name="_noDeleteConfirm"
id="_noDeleteConfirm"
value="{{ cfg.noDeleteConfirm }}"
role="switch"
{% if cfg.noDeleteConfirm %}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 form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_showQuestionCount"
id="_showQuestionCount"
value="{{ cfg.showQuestionCount }}"
role="switch"
{% if cfg.showQuestionCount %}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>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,86 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Import/Export') }}{% endblock %}
{% set import_link = 'active' %}
{% block _content %}
<h2 id="general" class="mb-2 fw-normal">{{ _('Import/Export') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Import or export your {} instance data').format(const.appName) }}</p>
<h3 id="import" class="mb-3 fw-light">{{ _('Import') }}</h3>
<div class="alert alert-info border-0 small" role="alert">
<p class="m-0 d-flex align-items-center gap-2 fw-medium"><i class="bi bi-info-circle fs-5"></i> {{ _('Note') }}</p>
<p class="m-0">
{{ _('Please note that import may take a while, depending on your database size') }}
</p>
</div>
<form class="mb-2" hx-disabled-elt="find button[type=submit]" hx-put="{{ url_for('api.importData') }}" hx-encoding="multipart/form-data" hx-target="#response-container" hx-swap="none">
<div class="form-group mb-3">
<label class="form-label" for="import_archive">{{ _('Import data') }}</label>
<input class="form-control" type="file" id="import_archive" name="import_archive" required>
<p class="form-text">{{ _('Note: Retrospring exports are not supported yet') }}</p>
</div>
<button type="submit" class="btn btn-primary mt-2" id="import-data">
<span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-database-up me-1"></i> {{ _('Import') }}
</button>
</form>
{#
<hr>
<form class="mb-2" hx-disabled-elt="find button[type=submit]" hx-put="{{ url_for('api.importRsData') }}" hx-encoding="multipart/form-data" hx-target="#response-container" hx-swap="none">
<div class="form-group mb-3">
<label class="form-label" for="import_archive_rs">Retrospring data</label>
<input class="form-control" type="file" id="import_archive_rs" name="import_archive" required>
<!-- <p class="form-text">Note: Retrospring exports are not supported yet</p> -->
</div>
<button type="submit" class="btn btn-primary mt-2" id="import-data">
<span class="me-1 spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
<i class="bi bi-database-up me-1"></i> Import
</button>
</form>
#}
<h3 id="export" class="mb-3 mt-5 fw-light">{{ _('Export') }}</h3>
<div class="form-group mb-3">
<form hx-post="{{ url_for('api.createExport') }}" hx-swap="none" hx-disabled-elt="#export-btn">
<button type="submit" class="btn btn-primary" id="export-btn">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-plus-lg me-1"></i> {{ _('New Export') }}
</button>
</form>
{% if exports %}
<div class="table-responsive mt-2">
<table class="table align-middle">
<thead>
<tr>
<th>{{ _('Date') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for export in exports %}
<tr id="export-{{ export.timestamp_esc }}">
<td>{{ export.timestamp }}</td>
<td>
<a href="/{{ export.downloadPath }}" class="btn btn-secondary btn-sm px-3 px-md-2"><i class="bi bi-download"></i> <span class="d-none d-lg-inline-block ms-1">{{ _('Download') }}</span></a>
<button class="btn btn-outline-danger btn-sm px-3 px-md-2" hx-target="#export-{{ export.timestamp_esc }}" hx-swap="delete" hx-delete="{{ url_for('api.deleteExport', timestamp=export.timestamp_esc) }}"><i class="bi bi-trash"></i> <span class="d-none d-lg-inline-block ms-1">{{ _('Delete') }}</span></button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-center my-3">{{ _('No exports created yet') }}</p>
{% endif %}
</div>
{% endblock %}
{% block _scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,41 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Information') }}{% endblock %}
{% set info_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="instance" class="mb-2 fw-normal">{{ _('Information') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Essential information about your {} instance').format(const.appName) }}</p>
<div class="form-group mb-3">
<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">
<p class="form-text">{{ _('Title of this {} instance').format(const.appName) }}</p>
</div>
<div class="form-group mb-3">
<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 spellcheck="false" id="instance.description" name="instance.description" class="form-control" style="height: 200px; resize: vertical;">{{ cfg.instance.description }}</textarea>
<p class="form-text">{{ _('Description of this {} instance').format(const.appName) }}</p>
</div>
<div class="form-group mb-3">
<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 spellcheck="false" id="instance.rules" name="instance.rules" class="form-control" style="height: 200px; resize: vertical;">{{ cfg.instance.rules }}</textarea>
<p class="form-text">{{ _('Rules of this {} instance').format(const.appName) }}</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="instance.image">{{ _('Relative image path') }}</label>
<input type="text" id="instance.image" name="instance.image" value="{{ cfg.instance.image }}" placeholder="/static/icons/favicon/android-chrome-512x512.png" 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-2">
<label class="form-label" for="instance.fullBaseUrl">{{ _('Base URL') }}</label>
<input type="text" id="instance.fullBaseUrl" name="instance.fullBaseUrl" value="{{ cfg.instance.fullBaseUrl }}" placeholder="https://ask.example.com" class="form-control">
<p class="form-text">{{ _('Full URL to homepage of this {} instance without a trailing slash').format(const.appName) }}</p>
</div>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}

View file

@ -0,0 +1,39 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Languages') }}{% endblock %}
{% set langs_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="general" class="mb-2 fw-normal">{{ _('Languages') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Language settings') }}</p>
<div class="form-group mb-3">
<label class="form-label" for="languages.default">{{ _('Default language') }}</label>
<select id="languages.default" name="languages.default" class="form-select">
<option value="en_US"{% if cfg.languages.default == 'en_US' %} selected{% endif %}>{{ _('English (US)') }}</option>
<option value="ru_RU"{% if cfg.languages.default == 'ru_RU' %} selected{% endif %}>{{ _('Russian') }}</option>
</select>
</div>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_languages.allowChanging"
id="_languages.allowChanging"
value="{{ cfg.languages.allowChanging }}"
role="switch"
{% if cfg.languages.allowChanging %}checked{% endif %}>
<input type="hidden" id="languages.allowChanging" name="languages.allowChanging" value="{{ cfg.languages.allowChanging }}">
<label for="_languages.allowChanging" class="form-check-label">{{ _("Allow users to locally change the language") }}</label>
</div>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -0,0 +1,54 @@
{% extends 'admin/base.html' %}
{% block _title %}{{ _('Notifications') }}{% endblock %}
{% set notif_link = 'active' %}
{% block _content %}
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none" hx-disabled-elt="#saveConfig">
<h2 id="general" class="mb-22 fw-normal">{{ _('Notifications') }}</h2>
<p class="fs-5 h3 text-body-secondary mb-3">{{ _('Configure notifications for new questions using') }} <a href="https://ntfy.sh/" target="_blank">ntfy</a></p>
<div class="form-check form-switch mb-3">
<input
class="form-check-input"
type="checkbox"
name="_ntfy.enabled"
id="_ntfy.enabled"
value="{{ cfg.ntfy.enabled }}"
role="switch"
{% if cfg.ntfy.enabled %}checked{% endif %}>
<input type="hidden" id="ntfy.enabled" name="ntfy.enabled" value="{{ cfg.ntfy.enabled }}">
<label for="_ntfy.enabled" class="form-check-label">{{ _('Enabled') }}</label>
</div>
<p class="form-label">{{ _('Server &amp; Topic') }}</p>
<div class="input-group mb-4">
<input type="text" id="ntfy.host" name="ntfy.host" value="{{ cfg.ntfy.host }}" class="form-control" aria-label="{{ _('Server') }}">
<span class="input-group-text">/</span>
<input type="text" id="ntfy.topic" name="ntfy.topic" value="{{ cfg.ntfy.topic }}" class="form-control" aria-label="{{ _('Topic') }}">
</div>
<h3 class="fw-light mb-2">{{ _('Credentials (optional)') }}</h3>
<p class="text-body-secondary mb-3">{{ _('Set credentials if the topic is protected') }}</p>
<div class="form-group mb-3 mt-2">
<label class="form-label" for="ntfy.user">{{ _('Username') }}</label>
<input type="text" id="ntfy.user" name="ntfy.user" value="{{ cfg.ntfy.user }}" class="form-control">
<p class="form-text">
{{ _('Topic user') }}
</p>
</div>
<div class="form-group mb-3">
<label class="form-label" for="ntfy.pass">{{ _('Password') }}</label>
<input type="password" id="ntfy.pass" name="ntfy.pass" value="{{ cfg.ntfy.pass }}" class="form-control">
<p class="form-text">
{{ _('Topic password') }}
</p>
</div>
{% include 'snippets/admin/saveBtn.html' %}
</form>
{% endblock %}
{% block _scripts %}
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
{% endblock %}

View file

@ -1,336 +0,0 @@
{% extends 'base.html' %}
{% block title %}Admin{% endblock %}
{% set adminLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/coloris.min.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>
<a class="btn d-lg-none mb-2" href="#preview">Skip to preview</a>
<a class="visually-hidden-focusable" href="#preview">Skip to preview</a>
<div class="row">
<div class="col mw-100">
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h2 id="instance" class="mb-3 fw-normal d-flex align-items-center justify-content-between">
Instance
<button type="button" class="btn btn-outline-secondary btn-sm" data-bs-toggle="collapse" data-bs-target="#preview-collapse" aria-expanded="true" aria-controls="preview-collapse"><i class="bi bi-arrows-expand-vertical me-1"></i> Toggle preview</button>
</h2>
<div class="form-group mb-3">
<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 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 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-2">
<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>
<div class="form-group mb-4">
<button type="submit" class="btn btn-primary" id="saveConfig">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</div>
</form>
<h2 id="customize" class="mb-3 fw-normal">Customize</h2>
<h3 class="fw-light">Favicon</h3>
<form hx-post="{{ url_for('api.uploadFavicon') }}" hx-target="#response-container" hx-swap="none" hx-encoding="multipart/form-data">
<p class="m-0">Current favicon: <img src="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}" width="32" height="32" alt="{{ cfg.instance.title }}'s icon" class="rounded"></p>
<div>
<label for="favicon" class="form-label">Upload favicon</label>
<input class="form-control" type="file" id="favicon" name="favicon">
</div>
<div class="mb-4">
<button type="submit" class="btn btn-primary mt-3" id="saveFavicon">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Upload
</button>
</div>
</form>
<form hx-post="{{ url_for('api.updateConfig') }}" hx-target="#response-container" hx-swap="none">
<h3 class="fw-light">Accent color</h3>
{# <h4 class="fw-light">Color</h4> #}
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.accentLight">Light theme</label>
<input type="text" name="style.accentLight" id="style.accentLight" value="{{ cfg.style.accentLight }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#6345d9</b></p>
</div>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.accentDark">Dark theme</label>
<input type="text" name="style.accentDark" id="style.accentDark" value="{{ cfg.style.accentDark }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#7259d9</b></p>
</div>
{# brain doesn't feel like implementing this rn (9/27/24)
<h4 class="fw-light mt-2">Background</h4>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.bgLight">Light theme</label>
<input type="text" name="style.bgLight" id="style.bgLight" value="{{ cfg.style.bgLight }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#ffffff</b></p>
</div>
<div class="form-group d-flex flex-column">
<label class="form-label" for="style.bgDark">Dark theme</label>
<input type="text" name="style.bgDark" id="style.bgDark" value="{{ cfg.style.bgDark }}" class="form-control" data-coloris>
<p class="form-text">Default: <b>#202020</b></p>
</div>
#}
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
name="_style.tintColors"
id="_style.tintColors"
value="{{ cfg.style.tintColors }}"
{% if cfg.style.tintColors == true %}checked{% endif %}>
<input type="hidden" id="style.tintColors" name="style.tintColors" value="{{ cfg.style.tintColors }}">
<label for="_style.tintColors" class="form-check-label">Tint all colors with accent color</label>
</div>
{#
<div class="form-check mb-3">
<input
class="form-check-input"
type="checkbox"
name="_style.tintBackground"
id="_style.tintBackground"
value="{{ cfg.style.tintBackground }}"
{% if cfg.style.tintBackground == true %}checked{% endif %}>
<input type="hidden" id="style.tintBackground" name="style.tintBackground" value="{{ cfg.style.tintBackground }}">
<label for="_style.tintBackground" class="form-check-label">Tint background with accent color</label>
</div>
#}
<h3 class="fw-light">Navbar link style</h3>
<div class="form-check">
<input class="form-check-input" type="radio" name="style.navStyle" id="style.navStyle.underline" value="underline" {% if cfg.style.navStyle == 'underline' %}checked{% endif %}>
<label class="form-check-label" for="style.navStyle.underline">
Underline <span class="text-body-secondary">(default)</span>
</label>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="radio" name="style.navStyle" id="style.navStyle.pills" value="pills" {% if cfg.style.navStyle == 'pills' %}checked{% endif %}>
<label class="form-check-label" for="style.navStyle.pills">
Pills
</label>
</div>
<h3 class="fw-light">Info box layout</h3>
<div class="form-check">
<input class="form-check-input" type="radio" name="style.infoBoxLayout" id="style.infoBoxLayout.column" value="column" {% if cfg.style.infoBoxLayout == 'column' %}checked{% endif %}>
<label class="form-check-label" for="style.infoBoxLayout.column">
Column <span class="text-body-secondary">(default)</span>
</label>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="radio" name="style.infoBoxLayout" id="style.infoBoxLayout.row" value="row" {% if cfg.style.infoBoxLayout == 'row' %}checked{% endif %}>
<label class="form-check-label" for="style.infoBoxLayout.row">
Row
</label>
</div>
<h3 class="fw-light">Trimmed content</h3>
<div class="form-group mb-3">
<label class="form-label" for="trimContentAfter">Trim content after (characters)</label>
<input type="number" id="trimContentAfter" name="trimContentAfter" value="{{ cfg.trimContentAfter }}" class="form-control">
<p class="form-text">Maximum length of content before it gets trimmed (used in sharing options); set to 0 to disable</p>
</div>
<h2 id="general" class="mb-3 fw-normal">General</h2>
<div class="form-group mb-3">
<label class="form-label" for="charLimit">Question character limit</label>
<input type="number" id="charLimit" name="charLimit" value="{{ cfg.charLimit }}" class="form-control">
<p class="form-text">Max length of a question in characters; questions extending this character limit will not be added to the database</p>
</div>
<div class="form-group mb-4">
<label class="form-label" for="anonName">Name for anonymous users</label>
<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>
<div class="form-check mb-2">
<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 }}"
{% 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 }}"
{% 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" id="saveConfig">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">Loading...</span>
Save
</button>
</div>
</form>
<hr class="mt-4 mb-4">
<form 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" class="fw-normal">Word blacklist</h2></label>
<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" 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="collapse show collapse-horizontal col" id="preview-collapse">
<div>
<div id="preview">
<h2 class="mb-3 fw-normal">Preview</h2>
<button class="text-warning-emphasis d-inline-flex align-items-center btn btn-sm btn-outline-secondary mw-100" type="button" data-bs-toggle="collapse" data-bs-target="#preview-warning"><i class="bi bi-exclamation-triangle me-2"></i> The preview might not be accurate <i class="bi bi-chevron-down ms-1 small"></i></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>
</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" id="rules-content">{{ cfg.instance.rules | render_markdown }}</div>
</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 src="{{ url_for('static', filename='js/coloris.min.js') }}"></script>
<script>
Coloris({
theme: 'square',
themeMode: 'auto',
formatToggle: true,
alpha: false,
swatches: [
'#c70f0f', // Red
'#db5d0e', // Orange
'#968829', // Yellow
'#217d1a', // Green
'#28b59b', // Turquoise
'#338FFF', // Blue
'#3358ff',
'#6345d9', // Indigo
'#A833FF', // Purple
'#d1298b' // Pink
],
});
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
document.querySelectorAll('.form-check-input[type=checkbox]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? 'True' : 'False';
});
});
</script>
<script>
const forms = document.querySelectorAll('form');
forms.forEach((form) => {
form.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response;
if (jsonResponse) {
const parsed = JSON.parse(jsonResponse);
const alertType = event.detail.successful ? 'success' : 'danger';
message = event.detail.successful ? parsed.message : parsed.error;
if (event.detail.target.id != "question-count") {
Toastify({
text: message,
duration: 3000,
gravity: "top",
position: "right",
stopOnFocus: true,
className: `alert alert-${alertType} shadow alert-dismissible`,
close: true
}).showToast();
}
}
});
});
</script>
{% endblock %}

View file

@ -1,17 +1,22 @@
{% extends 'base.html' %}
{% block title %}Admin Login{% endblock %}
{% block title %}{{ _('Admin Login') }}{% endblock %}
{% set loginLink = 'active' %}
{% block content %}
<div class="row">
<div class="col-lg-4 m-auto mt-5">
<div class="col-lg-3 m-auto mt-5">
<div class="card">
<div class="card-body">
<h2 class="text-center mb-4 mt-2">Login to {{ cfg.instance.title }}</h2>
<h2 class="text-center mb-4 mt-2">{{ _('Login to {}').format(cfg.instance.title) }}</h2>
<form action="{{ url_for('admin.login') }}" method="POST">
<div class="form-floating mb-3">
<input type="password" required class="form-control" id="admin_password" name="admin_password" placeholder="Password">
<label for="admin_password">Password</label>
<div class="form-floating mb-2">
<input type="password" required class="form-control" id="admin_password" name="admin_password" placeholder="{{ _('Password') }}">
<label for="admin_password">{{ _('Password') }}</label>
</div>
<button type="submit" class="btn btn-primary w-100">Login</button>
<div class="form-check mb-3">
<input type="checkbox" class="form-check-input" id="remember_me" name="remember_me" checked>
<label class="form-check-label" for="remember_me">{{ _('Stay logged in') }}</label>
</div>
<button type="submit" class="btn btn-primary w-100">{{ _('Login') }}</button>
</form>
</div>
</div>

View file

@ -1,13 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
{% if cfg.accessibility.userway.enabled %}
<script src="https://cdn.userway.org/widget.js" data-account="{{ cfg.accessibility.userway.account }}"></script>
{% endif %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% if not (cfg.style.overrideBaseStyles and cfg.style.useCustomCss) %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap.min.css') }}">
{% endif %}
{% if not (cfg.style.overrideCatAskStyles and cfg.style.useCustomCss) %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
{% if cfg.style.tintColors %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/tinted.css') }}">
{% endif %}
<style>
{% if cfg.accessibility.font == 'default' %}
@font-face {
font-family: "Rubik";
font-display: swap;
font-weight: 100 900;
src: url("/static/fonts/rubik.woff2") format('woff2-variations');
}
:root {
--bs-font-sans-serif: "Rubik", sans-serif;
}
{% elif cfg.accessibility.font == 'default' %}
:root {
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif;
}
{% elif cfg.accessibility.font == 'atkinson' %}
@font-face {
font-family: 'Atkinson Hyperlegible';
src: local('Atkinson Hyperlegible Bold'), local('AtkinsonHyperlegible-Bold'),
url('/static/fonts/Atkinson-Hyperlegible-Bold.woff2') format('woff2');
font-weight: bold;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Atkinson Hyperlegible';
src: local('Atkinson Hyperlegible Bold Italic'), local('AtkinsonHyperlegible-BoldItalic'),
url('/static/fonts/Atkinson-Hyperlegible-BoldItalic.woff2') format('woff2');
font-weight: bold;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Atkinson Hyperlegible';
src: local('Atkinson Hyperlegible Italic'), local('AtkinsonHyperlegible-Italic'),
url('/static/fonts/Atkinson-Hyperlegible-Italic.woff2') format('woff2');
font-weight: normal;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Atkinson Hyperlegible';
src: local('Atkinson Hyperlegible Regular'), local('AtkinsonHyperlegible-Regular'),
url('/static/fonts/Atkinson-Hyperlegible-Regular.woff2') format('woff2');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--bs-font-sans-serif: "Atkinson Hyperlegible", sans-serif;
--bs-body-font-size: 1.05rem;
}
.dropdown-toggle::after {
vertical-align: -.125em;
}
.btn:not(.btn-sm) {
--bs-btn-font-size: 1.02rem;
}
.btn-group-sm > .btn, .btn-sm {
--bs-btn-font-size: 0.91rem;
}
.nav {
--bs-nav-link-font-size: 1rem;
}
.bi::before, [class*=" bi-"]::before, [class^="bi-"]::before {
vertical-align: -.2em;
}
{% endif %}
[data-bs-theme=light] {
--bs-primary: {{ cfg.style.accentLight }} !important;
}
[data-bs-theme=dark] {
--bs-primary: {{ cfg.style.accentDark }} !important;
}
</style>
{% endif %}
{% if cfg.style.customCss and cfg.style.useCustomCss %}
<style>
{{ cfg.style.customCss | safe }}
</style>
{% endif %}
<style>
/* some essential styles so 3rd-party themes don't break */
.markdown-content p {
margin: 0;
}
.markdown-content ol {
margin-bottom: 0;
}
.markdown-content blockquote {
border-left: 3px solid var(--bs-border-color);
}
.markdown-content blockquote p {
margin-left: .5rem;
}
.markdown-content p:not(:first-child) {
margin-top: .5rem;
}
.fw-buttons button {
width: 100%;
margin-top: .25rem;
margin-bottom: .25rem;
}
.tab {
display: inline-flex;
min-width: 130px;
align-items: center;
gap: .25em;
}
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
display: inline-block;
}
.btn-close {
--bs-btn-close-bg: none;
}
.ts-share {
max-width: 300px;
text-overflow: ellipsis;
align-content: center;
}
.no-arrow.dropdown-toggle::after {
border: none;
display: none;
}
{#- for compatibility #}
{%- if cfg.style.useCustomCss and cfg.style.overrideCatAskStyles %}
.modal-header .btn-close i {
display: none;
}
{%- endif %}
@media screen and (max-width: 1200px) {
.ts-share {
max-width: 250px;
}
}
@media screen and (min-width: 620px) and (max-width: 991px) {
.modal-dialog {
--bs-modal-width: 95%;
}
}
@media screen and (max-width: 767px) {
.ts-share {
max-width: unset;
width: 100%;
}
}
@media screen and (min-width: 767px) and (max-width: 840px) {
.ts-share {
max-width: 190px;
}
}
</style>
<link rel="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.min.css') }}">
<link rel="preload" href="{{ url_for('static', filename='fonts/bootstrap-icons.woff2') }}" as="font" type="font/woff2" crossorigin>
{%- if cfg.accessibility.font == 'default' -%}
<link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
{%- endif -%}
<!-- favicon -->
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}">
@ -36,50 +205,60 @@
<meta property="twitter:description" content="{{ metadata.description }}" />
<meta property="twitter:image" content="{{ metadata.image }}" />
<!-- pwa manifest -->
<link rel="manifest" href="{{ url_for('api.pwaManifest') }}" />
<script src="{{ url_for('static', filename='js/color-modes.js') }}"></script>
{% if cfg.style.tintColors %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/tinted.css') }}">
{% endif %}
{% block additionalHeadItems %}{% endblock %}
<style>
[data-bs-theme=light] {
--bs-primary: {{ cfg.style.accentLight }} !important;
}
[data-bs-theme=dark] {
--bs-primary: {{ cfg.style.accentDark }} !important;
}
</style>
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
<title>{% block title %}{% endblock %} | {{ cfg.instance.title }}</title>
</head>
<body class="ms-2 me-2 mb-2">
<body class="">
<a class="visually-hidden-focusable btn" href="#main-content">Skip to content</a>
<div class="container-fluid">
<div class="d-flex {% if logged_in %}justify-content-between {% endif %}align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<div class="mb-2{% if not bodyNoXMargin %} px-3{% endif %} col-xxl-11{% if not noContainerFluid %} container-fluid{% endif %}">
{% block navbar %}
<div class="d-flex justify-content-between align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
<ul class="nav nav-{{ cfg.style.navStyle }} position-relative">
<li class="nav-item d-flex align-items-center {% if cfg.style.navStyle == 'pills' %}me-1{% endif %}"><a href="{{ url_for('index') }}" aria-label="{{ cfg.instance.title }}'s icon"><img src="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}" width="32" height="32" alt="{{ cfg.instance.title }}'s icon"></a></li>
<li class="nav-item d-flex align-items-center"><a class="nav-link {{ homeLink }}" id="home-link" href="{{ url_for('index') }}">Home</a></li>
<li class="nav-item d-flex align-items-center {% if cfg.style.navStyle == 'pills' %}me-1{% endif %}"><a href="{{ url_for('index') }}" aria-label="{{ cfg.instance.title }}'s icon"><img src="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}" loading="lazy" width="32" height="32" alt="{{ cfg.instance.title }}'s icon"></a></li>
<ul class="d-flex p-0">
{% include 'snippets/navLinks.html' %}
</ul>
</ul>
<ul class="nav nav-{{ cfg.style.navStyle }} m-0">
{% if logged_in %}
<li class="nav-item d-flex align-items-center position-relative">
<a class="nav-link {{ inboxLink }}" id="inbox-link" href="{{ url_for('inbox') }}">
Inbox {# <span class="position-absolute start-100 translate-middle badge text-bg-primary rounded-pill">{{ questionCount }} <span class="visually-hidden">unanswered questions</span></span> #}
<form action="{{ url_for('admin.logout') }}" method="POST" class="d-none" id="logout_form"></form>
<li>
<button form="logout_form" type="submit" class="nav-link"{% if cfg.style.navIconsOnly %} title="{{ _('Logout') }}"{% endif %}>
{% if cfg.style.navIcons %}<i class="bi bi-box-arrow-right fs-mob-5{% if cfg.style.navIconsOnly %} fs-5{% endif %}"></i>{% endif %}
{% if not cfg.style.navIconsOnly %}<span{% if cfg.style.navIcons %} class="d-none d-lg-inline ms-1"{% endif %}>{{ _('Logout') }}</span>{% endif %}
</button>
</li>
{% else %}
<li>
<a class="nav-link {{ loginLink }}" href="{{ url_for('admin.login') }}"{% if cfg.style.navIconsOnly %} title="{{ _('Login') }}"{% endif %}>
{% if cfg.style.navIcons %}<i class="bi bi-box-arrow-in-right fs-mob-5{% if cfg.style.navIconsOnly %} fs-5{% endif %}"></i>{% endif %}
{% if not cfg.style.navIconsOnly %}<span{% if cfg.style.navIcons %} class="d-none d-lg-inline ms-1"{% endif %}>{{ _('Login') }}</span>{% endif %}
</a>
</li>
<li class="nav-item d-flex align-items-center"><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-{{ cfg.style.navStyle }} m-0">
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
</ul>
{% endif %}
</div>
{# will do later
<div class="d-flex border-top bg-body z-3 px-3 py-2 d-md-none fixed-bottom mobile-nav">
<ul class="nav nav-{{ cfg.style.navStyle }} position-relative d-flex justify-content-between w-100">
{% with mobileNav = True %}
{% include 'snippets/navLinks.html' %}
{% endwith %}
</ul>
</div>
#}
{% endblock %}
{% with messages = get_flashed_messages(with_categories=True) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
<div>{{ message }}</div>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
<div class="alert alert-{{ category }} alert-dismissible col-lg-4 m-auto" role="alert">
<p class="m-0">{{ message }}</p>
<button type="button" class="btn-close d-flex align-items-stretch fs-5 p-3" data-bs-dismiss="alert" aria-label="Close"><i class="bi bi-x-lg lh-sm"></i></button>
</div>
{% endfor %}
{% endif %}
@ -87,8 +266,9 @@
<div id="main-content">
{% block content %}{% endblock %}
</div>
{% block footer %}
<footer class="py-3 my-4 d-flex justify-content-between align-items-center">
<div class="row">
<div class="d-flex gap-2">
<div class="dropdown bd-mode-toggle">
<button class="btn btn-outline-secondary py-2 dropdown-toggle"
id="bd-theme"
@ -98,38 +278,95 @@
data-bs-auto-close="outside"
aria-label="Toggle theme (auto)">
<i class="bi bi-circle-half my-1" id="theme-icon-active"></i>
<span class="visually-hidden" id="bd-theme-text">Toggle theme</span>
<span class="visually-hidden" id="bd-theme-text">{{ _('Toggle theme') }}</span>
</button>
<ul class="dropdown-menu" aria-labelledby="bd-theme-text">
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-sun-fill"></i> Light
<i class="bi me-2 opacity-50 bi-sun-fill"></i> {{ _('Light') }}
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-moon-stars-fill"></i> Dark
<i class="bi me-2 opacity-50 bi-moon-stars-fill"></i> {{ _('Dark') }}
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
<li>
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto" aria-pressed="false">
<i class="bi me-2 opacity-50 bi-circle-half"></i> Auto
<i class="bi me-2 opacity-50 bi-circle-half"></i> {{ _('Auto') }}
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
</button>
</li>
</ul>
</div>
{% if cfg.languages.allowChanging %}
<div class="dropdown">
<button class="btn btn-outline-secondary py-2 dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ _('Change language') }}">
<i class="bi bi-translate opacity-50 me-2"></i>
<span class="visually-hidden">{{ _('Change language') }}</span>
</button>
<ul class="dropdown-menu">
<form action="{{ url_for('api.changeLanguage') }}" method="POST">
{% for language in config.available_languages.items() %}
<li>
<button type="submit" name="lang" value="{{ language[0] }}" class="dropdown-item d-flex align-items-center{% if language[0] == session.get('language') %} active{% endif %}" aria-pressed="false">
{{ _(language[1]) }}
<i class="bi ms-auto{% if language[0] != session.get('language') %} d-none{% endif %} bi-check2"></i>
</button>
</li>
{% endfor %}
</form>
</ul>
</div>
{% endif %}
</div>
<div class="text-body-secondary text-end small">
<p class="text-decoration-none m-0 d-flex align-items-center gap-1">
<img src="{{ url_for('static', filename='icons/catask.svg') }}" width="20" height="20" alt="{{ const.appName }} logo">
{{ const.appName }} <span class="fw-medium">{{ version }}{{ version_id }}</span>
</p>
<a href="https://git.gay/mst/catask" class="icon-link text-decoration-none" target="_blank"><i class="bi bi-git"></i> Source code</a>
<div class="d-flex gap-2 fs-5 justify-content-end">
<a href="{{ const.homepageUrl }}" class="icon-link text-decoration-none" target="_blank" title="{{ _('Website') }}">
<i class="bi bi-globe2"></i>
<span class="visually-hidden">{{ _('Website') }}</span>
</a>
<a href="{{ const.docsUrl }}" class="icon-link text-decoration-none" target="_blank" title="{{ _('Documentation') }}">
<i class="bi bi-book-half"></i>
<span class="visually-hidden">{{ _('Documentation') }}</span>
</a>
<a href="{{ const.social.bskyUrl }}" class="icon-link text-decoration-none" target="_blank" title="{{ _('Bluesky') }}">
<svg width="20" height="20" viewBox="0 0 568 501" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0535 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z"></path>
</svg>
<span class="visually-hidden">{{ _('Bluesky') }}</span>
</a>
<a href="{{ const.social.fediUrl }}" class="icon-link text-decoration-none" target="_blank" title="{{ _('Fediverse') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 64 64">
<path fill="currentColor" d="M12.088 23.868a6.734 6.732 0 0 1-2.88 2.866L25.02 42.602l3.812-1.93Zm20.857 20.93-3.812 1.932 8.012 8.04a6.734 6.732 0 0 1 2.88-2.866z"></path>
<path fill="currentColor" d="m51.24 30.147-8.952 4.535.66 4.22 10.128-5.131a6.734 6.732 0 0 1-1.837-3.624Zm-14.15 7.168L15.926 48.038a6.734 6.732 0 0 1 1.837 3.624l19.989-10.127z"></path>
<path fill="currentColor" d="M30.284 10.9 20.071 30.833l3.016 3.027L33.9 12.755a6.734 6.732 0 0 1-3.616-1.854zm-12.87 25.117-5.172 10.095a6.734 6.732 0 0 1 3.615 1.855l4.573-8.925z"></path>
<path fill="currentColor" d="M9.12 26.778a6.734 6.732 0 0 1-3.364.703 6.734 6.732 0 0 1-.65-.068l3.02 19.316a6.734 6.732 0 0 1 3.365-.703 6.734 6.732 0 0 1 .65.068z"></path>
<path fill="currentColor" d="M17.779 51.758a6.734 6.732 0 0 1 .07 1.356 6.734 6.732 0 0 1-.71 2.656l19.318 3.099a6.734 6.732 0 0 1-.07-1.356 6.734 6.732 0 0 1 .71-2.656Z"></path>
<path fill="currentColor" d="m53.144 33.841-8.917 17.402a6.734 6.732 0 0 1 3.617 1.855l8.916-17.402a6.734 6.732 0 0 1-3.616-1.855z"></path>
<path fill="currentColor" d="M40.983 9.229a6.734 6.732 0 0 1-2.88 2.866L51.91 25.953a6.734 6.732 0 0 1 2.88-2.867z"></path>
<path fill="currentColor" d="M28.38 7.206 10.922 16.05a6.734 6.732 0 0 1 1.837 3.624l17.456-8.844a6.734 6.732 0 0 1-1.837-3.624Z"></path>
<path fill="currentColor" d="M38.07 12.111a6.734 6.732 0 0 1-3.42.731 6.734 6.732 0 0 1-.589-.062l1.546 9.898 4.22.677zm-1.564 16.322 3.656 23.402a6.734 6.732 0 0 1 3.315-.678 6.734 6.732 0 0 1 .705.077L40.726 29.11Z"></path>
<path fill="currentColor" d="M12.772 19.748a6.734 6.732 0 0 1 .075 1.377 6.734 6.732 0 0 1-.7 2.637l9.909 1.59 1.947-3.801zm16.984 2.726-1.948 3.803 23.413 3.759a6.734 6.732 0 0 1-.068-1.341 6.734 6.732 0 0 1 .718-2.67z"></path>
<circle fill="currentColor" cx="35.017" cy="6.12" r="6.12"></circle>
<circle fill="currentColor" cx="57.878" cy="29.062" r="6.12"></circle>
<circle fill="currentColor" cx="43.111" cy="57.88" r="6.12"></circle>
<circle fill="currentColor" cx="11.124" cy="52.749" r="6.12"></circle>
<circle fill="currentColor" cx="6.122" cy="20.759" r="6.12"></circle>
</svg>
<span class="visually-hidden">{{ _('Fediverse') }}</span>
</a>
<a href="{{ const.repoUrl }}" class="icon-link text-decoration-none" target="_blank" title="{{ _('Source code') }}"><i class="bi bi-git"></i> <span class="visually-hidden">{{ _('Source code') }}</span></a>
</div>
</div>
</footer>
{% endblock %}
</div>
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
{% block scripts %}{% endblock %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/base.html' %}
{% set error_code = '404' %}
{% set error_title = 'Not Found' %}
{% set error_description = 'The requested resource could not be found.' %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/base.html' %}
{% set error_code = '500' %}
{% set error_title = 'Internal Server Error' %}
{% set error_description = 'The server was unable to complete your request. Please try again later. If this problem persists, please contact the admin of this {} instance.'.format(const.appName) %}

View file

@ -0,0 +1,4 @@
{% extends 'errors/base.html' %}
{% set error_code = '502' %}
{% set error_title = 'Bad Gateway' %}
{% set error_description = 'The server was unable to complete your request. Please try again later. If this problem persists, please contact the admin of this {} instance.'.format(const.appName) %}

View file

@ -0,0 +1,9 @@
{% extends 'base.html' %}
{% block title %}{{ error_title }}{% endblock %}
{% block content %}
<div class="mx-auto mb-5 pb-5">
<h1 class="mt-5 pt-5 text-center display-1 text-body-secondary">{{ error_code }}</h1>
<h2 class="text-center fw-normal">{{ error_title }}</h2>
<p class="lead text-center">{{ error_description }}<br><br><a class="btn btn-primary px-5" href="{{ url_for('index') }}">Home</a></p>
</div>
{% endblock %}

View file

@ -1,26 +1,26 @@
{% 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' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %}
{% if questions != [] %}
<h3 class="fs-4"><span id="question-count-inbox">{{ len(questions) }}</span> <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="col-lg-8 m-auto">
<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">
<h5 class="card-title mt-1 mb-0 markdown-content w-50">
{% if question.from_who %}
{{ question.from_who }}
{{ render_markdown(question.from_who, fromWho=True) }}
{% 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 }}
<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">
<h6 class="card-subtitle mt-1 fw-light text-body-secondary">
{#
reserved for version 1.6.0 or later
@ -31,26 +31,56 @@
<span data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime('%B %d, %Y %H:%M') }}">{{ formatRelativeTime(str(question.creation_date)) }}</span>
</h6>
</div>
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
<div class="card-text markdown-content">
{% if question.cw %}
<p class="text-center mb-2 fw-bold cw-text">{{ question.cw }}</p>
<div class="collapse question-cw" id="question-cw-{{ question.id }}">
{{ question.content | render_markdown }}
</div>
<button class="z-0 cw-btn btn btn-sm btn-secondary shadow text-center w-100 sticky-bottom" type="button" data-bs-toggle="collapse" data-bs-target="#question-cw-{{ question.id }}" aria-expanded="false" aria-controls="question-cw-{{ question.id }}">
<span class="fw-medium cw-btn-text">{{ _('Show content') }}</span>
<span class="text-body-secondary cw-btn-chars">({{ _("{} characters").format(len(question.content)) }})</span>
</button>
{% else %}
{{ question.content | render_markdown }}
{% endif %}
</div>
</div>
<div class="card-body">
<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">
<form hx-trigger="click from:#answer-btn-{{ question.id }}, keyup[ctrlKey&&key=='Enter']" hx-disabled-elt="find button[type=submit]" 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">
<div class="collapse" id="cw-{{ question.id }}-collapse">
<div class="form-floating mb-2">
<input class="form-control" type="text" name="cw" id="cw" placeholder="Content warning">
<label for="cw">{{ _('Content warning') }}</label>
</div>
</div>
<div class="form-floating">
<textarea class="form-control" style="height: 6em;" required name="answer" id="answer-{{ question.id }}" placeholder="{{ _('Write your answer...') }}"></textarea>
<label for="answer-{{ question.id }}">{{ _('Write your answer...') }}</label>
</div>
<div class="d-flex flex-column flex-sm-row justify-content-sm-between align-items-sm-center gap-2 mt-3">
<button class="btn btn-secondary" type="button" title="{{ _('Content warning') }}" data-bs-toggle="collapse" data-bs-target="#cw-{{ question.id }}-collapse" aria-expanded="false" aria-controls="cw-{{ question.id }}-collapse">
<i class="bi bi-exclamation-triangle"></i>
<span class="ms-1">{{ _("Add CW") }}</span>
</button>
<div class="d-flex flex-column flex-sm-row-reverse gap-2">
<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
<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>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">
<i class="bi bi-trash"></i>
<span class="d-inline d-sm-none ms-1">{{ _('Delete') }}</span>
</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>
<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>
</form>
</div>
</div>
@ -58,16 +88,16 @@
<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 class="modal-header border-0">
<h1 class="modal-title fs-5 fw-normal" id="q-{{ question.id }}-modal-label">{{ _('Confirmation') }}</h1>
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-dismiss="modal" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body">
<p class="mb-0">Are you sure you want to delete this question?</p>
<div class="modal-body pt-0 pb-0">
<p>{{ _('Are you sure you want to delete this question?') }}</p>
</div>
<div class="modal-footer flex-column flex-lg-row align-items-stretch w-100">
<button type="button" class="btn btn-outline-secondary flex-fill" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger flex-fill" 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 class="modal-footer pt-1 border-0 flex-column flex-sm-row align-items-stretch align-items-sm-center">
<button type="button" class="btn btn-outline-secondary" 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>
@ -77,7 +107,7 @@
{% endfor %}
</div>
{% else %}
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
<h2 class="text-center mt-5">{{ _('Inbox is currently empty.') }}</h2>
{% endif %}
{% endblock %}
{% block scripts %}
@ -86,6 +116,34 @@
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
document.addEventListener('DOMContentLoaded', function () {
const collapseElements = document.querySelectorAll('.collapse.question-cw');
const toggleButtons = document.querySelectorAll('.cw-btn');
const cwTexts = document.querySelectorAll('.cw-text');
collapseElements.forEach(function (collapseElement, index) {
let toggleButton = toggleButtons[index];
let cwText = cwTexts[index];
let buttonText = toggleButton.querySelector('.cw-btn-text');
let buttonCharsText = toggleButton.querySelector('.cw-btn-chars');
collapseElement.addEventListener('show.bs.collapse', function () {
buttonText.textContent = 'Hide content';
buttonCharsText.classList.add('d-none');
cwText.classList.remove('text-center');
cwText.classList.remove('fw-bold');
});
collapseElement.addEventListener('hide.bs.collapse', function () {
buttonText.textContent = 'Show content';
buttonCharsText.classList.remove('d-none');
cwText.classList.add('text-center');
cwText.classList.add('fw-bold');
});
});
});
document.addEventListener('htmx:afterRequest', function(event) {
const jsonResponse = event.detail.xhr.response;
if (jsonResponse) {
@ -107,9 +165,9 @@
const questions = document.querySelectorAll('.question');
const count = questions.length;
document.getElementById('question-count-inbox').textContent = count;
document.title = `Inbox (${count}) | {{ const.appName }}`;
document.title = `Inbox (${count}) | {{ const.appName }}` ? count > 0 : "Inbox | {{ const.appName }}";
}
}
});
})
</script>
{% endblock %}

View file

@ -1,248 +1,32 @@
{% extends 'base.html' %}
{% block title %}Home{% endblock %}
{% block title %}{{ _('Home') }}{% endblock %}
{% set homeLink = 'active' %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% if cfg.antispam.enabled %}
{% if cfg.antispam.type == 'recaptcha' %}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
{% elif cfg.antispam.type == 'turnstile' %}
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" defer></script>
{% elif cfg.antispam.type == 'frc' %}
<script type="module" src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.9/site.min.js" async defer></script>
<script nomodule src="https://cdn.jsdelivr.net/npm/@friendlycaptcha/sdk@0.1.9/site.compat.min.js" async defer></script>
{% endif %}
{% endif %}
{% endblock %}
{% block content %}
<div class="mt-5 mb-sm-2 mb-md-5 col-sm-{% if cfg.style.infoBoxLayout == 'row' %}10{% else %}8{% endif %} m-auto{% if cfg.style.infoBoxLayout == 'row' %} d-lg-flex justify-content-between gap-2{% endif %}">
<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>
{% if len(cfg.instance.rules) > 0 %}
<div class="m-auto col-sm-{% if cfg.style.infoBoxLayout == 'row' %}6{% else %}8{% endif %}">
<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 id="top-response-container"></div>
{% if cfg.style.homepageLayout == 'catask' %}
{% include 'snippets/layout/homepage/normal.html' %}
{% elif cfg.style.homepageLayout == 'retrospring' %}
{% include 'snippets/layout/homepage/retrospring.html' %}
{% endif %}
</div>
<div class="row">
<div class="col-sm-{% if combined %}4{% else %}6{% endif %}{% if not combined %} m-auto{% endif %}">
<div class="mb-5 sticky-md-top">
{% if cfg.lockInbox == false %}
<br>
<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 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 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>
<input class="form-control" type="text" required name="antispam" id="antispam" autocomplete="off">
</div>
<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.6.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" 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>
</div>
</form>
<div id="response-container" class="mt-3"></div>
{% else %}
<br>
<h2 class="text-center">New questions cannot be asked right now.</h2>
{% endif %}
</div>
</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{% if item.question.pinned %} border-2{% endif %}" id="question-{{ item.question.id }}">
<div class="position-relative">
<div class="card-header{% if item.question.pinned %} border-2{% endif %}">
<div class="d-flex justify-content-between align-items-center flex-wrap text-break">
<h3 class="h5 card-title mt-1 mb-1">
{% if item.question.from_who %}
{{ item.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 %}
</h3>
<h3 class="h6 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)) }}</h3>
</div>
<div class="card-text markdown-content">{{ item.question.content | render_markdown }}</div>
<!-- <h4 class="position-absolute bottom-0 end-0 fw-light mb-0 me-2 fs-2">#{{item.question.id}}</h4> -->
</div>
</div>
<div class="card-body">
{% 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-1 text-body-secondary d-flex justify-content-between align-items-center{% if item.question.pinned %} border-2{% endif %}">
<div>
<span class="fs-6" data-bs-toggle="tooltip" data-bs-title="{{ answer.creation_date.strftime("%B %d, %Y %H:%M") }}">{{ 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>
<div class="d-flex align-items-center">
<a href="{{ url_for('viewQuestion', question_id=item.question.id) }}" class="btn btn-basic pt-2 pb-2 text-body-secondary" data-bs-toggle="tooltip" data-bs-title="View question" aria-label="View question"><i class="bi bi-box-arrow-up-right"></i></a>
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Share question"><i class="bi bi-share"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copyFull(`{{ trimContent(item.question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter) }} {{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=item.question.id) }}`)"><i class="bi bi-copy me-1"></i> Copy to clipboard</button></li>
<li>
<button class="dropdown-item d-flex align-items-center gap-1" data-bs-toggle="modal" data-bs-target="#question-{{ item.question.id }}-modal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64" class="me-1">
<path fill="currentColor" d="M12.088 23.868a6.734 6.732 0 0 1-2.88 2.866L25.02 42.602l3.812-1.93Zm20.857 20.93-3.812 1.932 8.012 8.04a6.734 6.732 0 0 1 2.88-2.866z"/>
<path fill="currentColor" d="m51.24 30.147-8.952 4.535.66 4.22 10.128-5.131a6.734 6.732 0 0 1-1.837-3.624Zm-14.15 7.168L15.926 48.038a6.734 6.732 0 0 1 1.837 3.624l19.989-10.127z"/>
<path fill="currentColor" d="M30.284 10.9 20.071 30.833l3.016 3.027L33.9 12.755a6.734 6.732 0 0 1-3.616-1.854zm-12.87 25.117-5.172 10.095a6.734 6.732 0 0 1 3.615 1.855l4.573-8.925z"/>
<path fill="currentColor" d="M9.12 26.778a6.734 6.732 0 0 1-3.364.703 6.734 6.732 0 0 1-.65-.068l3.02 19.316a6.734 6.732 0 0 1 3.365-.703 6.734 6.732 0 0 1 .65.068z"/>
<path fill="currentColor" d="M17.779 51.758a6.734 6.732 0 0 1 .07 1.356 6.734 6.732 0 0 1-.71 2.656l19.318 3.099a6.734 6.732 0 0 1-.07-1.356 6.734 6.732 0 0 1 .71-2.656Z"/>
<path fill="currentColor" d="m53.144 33.841-8.917 17.402a6.734 6.732 0 0 1 3.617 1.855l8.916-17.402a6.734 6.732 0 0 1-3.616-1.855z"/>
<path fill="currentColor" d="M40.983 9.229a6.734 6.732 0 0 1-2.88 2.866L51.91 25.953a6.734 6.732 0 0 1 2.88-2.867z"/>
<path fill="currentColor" d="M28.38 7.206 10.922 16.05a6.734 6.732 0 0 1 1.837 3.624l17.456-8.844a6.734 6.732 0 0 1-1.837-3.624Z"/>
<path fill="currentColor" d="M38.07 12.111a6.734 6.732 0 0 1-3.42.731 6.734 6.732 0 0 1-.589-.062l1.546 9.898 4.22.677zm-1.564 16.322 3.656 23.402a6.734 6.732 0 0 1 3.315-.678 6.734 6.732 0 0 1 .705.077L40.726 29.11Z"/>
<path fill="currentColor" d="M12.772 19.748a6.734 6.732 0 0 1 .075 1.377 6.734 6.732 0 0 1-.7 2.637l9.909 1.59 1.947-3.801zm16.984 2.726-1.948 3.803 23.413 3.759a6.734 6.732 0 0 1-.068-1.341 6.734 6.732 0 0 1 .718-2.67z"/>
<circle fill="currentColor" cx="35.017" cy="6.12" r="6.12"/>
<circle fill="currentColor" cx="57.878" cy="29.062" r="6.12"/>
<circle fill="currentColor" cx="43.111" cy="57.88" r="6.12"/>
<circle fill="currentColor" cx="11.124" cy="52.749" r="6.12"/>
<circle fill="currentColor" cx="6.122" cy="20.759" r="6.12"/>
</svg>
Share on Fediverse
</button>
</li>
<li><a class="dropdown-item" target="_blank" href="https://twitter.com/intent/tweet?text={{ urllib.parse.quote(trimContent(item.question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=item.question.id), safe='') }}"><i class="bi bi-twitter me-1"></i> Share on Twitter</a></li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://bsky.app/intent/compose?text={{ urllib.parse.quote(trimContent(item.question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=item.question.id), safe='') }}">
<svg width="16" height="16" class="me-1" viewBox="0 0 568 501" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0535 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z" fill="black"/>
</svg>
Share on Bluesky
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title={{ urllib.parse.quote(trimContent(item.question.content, cfg.trimContentAfter), safe='') }}&url={{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=item.question.id), safe='') }}&caption=&content={{ urllib.parse.quote(trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=item.question.id), safe='') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" class="me-1">
<path d="M6.27051 7.62976C8.86829 7.07312 10.816 4.76401 10.816 2H13.8463V7.15152H17.4826V10.7879H13.8463V16.2424C13.8463 16.7566 14.044 17.4493 14.7554 17.9091C15.2296 18.2156 16.2397 18.3671 17.7857 18.3636V22H13.5432C11.0329 22 8.99778 19.9649 8.99778 17.4545V10.7879H6.27051V7.62976Z"></path>
</svg>
Share on Tumblr
</a>
</li>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="Miscellaneous menu"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ item.question.id }})"><i class="bi bi-copy me-1"></i> 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"><i class="bi bi-pin me-1"></i> 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"><i class="bi bi-pin-angle me-1"></i> 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="#top-response-container" hx-swap="none" data-returntoinbox data-target="question-{{ item.question.id }}"><i class="bi bi-arrow-return-left me-1"></i> Return to inbox</button></li>
{% endif %}
</ul>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% for item in combined %}
{% for answer in item.answers %}
<div class="modal fade" id="question-{{ item.question.id }}-modal" tabindex="-1" aria-labelledby="q-{{ item.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-{{ item.question.id }}-modal-label">Share on Fediverse</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-group mb-3">
<label for="fediInstance">Fediverse instance domain:</label>
<input type="text" id="fediInstance-{{item.question.id}}" name="fediInstance" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="shareOnFediverse('{{ item.question.id }}', `{{ urllib.parse.quote(trimContent(item.question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + '/q/' + str(item.question.id),safe='') }}`)">Share</button>
</div>
</div>
</div>
</div>
{% endfor %}
{% endfor %}
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/emoji-mart.js') }}"></script>
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? '1' : '0';
});
});
</script>
<script>
function copy(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();
}
{% if not cfg.lockInbox %}
const input = document.getElementById('question');
const charCount = document.getElementById('charCount');
function updateCharCount() {
@ -263,30 +47,232 @@
}
}
input.addEventListener('input', updateCharCount);
document.getElementById('question-form').reset();
{% endif %}
document.addEventListener("DOMContentLoaded", (event) => {
const emoji_picker = document.querySelector('em-emoji-picker');
const root_ = emoji_picker.shadowRoot;
const sheet = new CSSStyleSheet();
sheet.replaceSync(`
/* custom css start */
:host {
--font-family: var(--bs-font-sans-serif) !important;
--border-radius: var(--bs-border-radius) !important;
box-shadow: none !important;
}
#nav button[aria-selected] {
color: var(--bs-primary) !important;
}
#nav .bar {
background-color: var(--bs-primary) !important;
}
.search .icon {
color: var(--bs-tertiary-color) !important;
}
.category button .background {
background-color: var(--bs-basic-btn-hover-bg-strong);
}
:host, #root, input, button {
color: var(--bs-body-color);
}
#root {
--color-a: var(--bs-body-color) !important;
--color-b: var(--bs-secondary-color) !important;
--color-c: var(--bs-tertiary-color) !important;
}
#preview .emoji-mart-emoji {
font-size: 16px;
}
[data-theme="dark"] {
--em-rgb-background: !important;
}
.search input[type="search"] {
border: 1px solid transparent !important;
background-color: var(--bs-body-bg) !important;
}
.search input[type="search"]:focus {
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent) !important;
border-color: color-mix(in srgb, var(--bs-primary) 80%, transparent) !important;
outline: 0;
}
.category:first-child {
margin-top: 1em;
}
/* custom css end */
`);
emoji_picker.shadowRoot.adoptedStyleSheets = [...root_.adoptedStyleSheets, sheet];
});
const question_textarea = document.getElementById('question');
function log_(a) {
if (question_textarea.value != "") {
question_textarea.value = a.native ? question_textarea.value + " " + a.native : question_textarea.value + " " + a.shortcodes;
} else {
question_textarea.value = a.native ? a.native : a.shortcodes;
}
updateCharCount();
}
const pickerOptions = {
// document.getElementById('emoji-picker')
onEmojiSelect: log_,
parent: document.getElementById('emoji-picker'),
custom: [
{
emojis: [
{% for emoji in emojis %}
{
id: "{{ emoji.name }}",
name: "{{ emoji.name }}",
keywords: ["{{ emoji.name }}"],
skins: [{ src: "{{ emoji.image }}" }],
},
{% endfor %}
],
},
{% for pack in packs %}
{
id: "{{ pack.name }}",
name: "{{ pack.name }}",
emojis: [
{% for emoji in pack.emojis %}
{
id: "{{ emoji.name }}",
name: "{{ emoji.name }}",
keywords: ["{{ emoji.name }}"],
skins: [{ src: "/{{ pack.relative_path }}/{{ emoji.file_name }}" }],
},
{% endfor %}
],
},
{% endfor %}
]
}
const picker = new EmojiMart.Picker(pickerOptions)
</script>
<script>
// fix handling checkboxes
document.querySelectorAll('.form-check-input').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
checkbox.nextElementSibling.value = this.checked ? '1' : '0';
});
});
</script>
<script>
function nativeShare(title, text, url) {
const shareData = {
title: title,
text: text,
url: url,
};
const shareBtns = document.querySelectorAll(".nativeShareBtn");
shareBtns.forEach((shareBtn) => {
shareBtn.addEventListener("click", async () => {
try {
if (navigator.canShare(shareData)) {
await navigator.share(shareData);
}
}
catch (err) {
console.error(err);
}
});
});
};
</script>
<script>
const questionModal = document.getElementById('question-modal');
questionModal.addEventListener('show.bs.modal', event => {
// Button that triggered the modal
let button = event.relatedTarget;
// Extract info from data-bs-* attributes
let questionId = button.getAttribute('data-q-id');
let questionContent = document.querySelector(`.question-${questionId}`).innerText;
let answerContent = document.getElementById(`a-${questionId}-content`).innerText;
let submitBtn = document.getElementById('q-modal-submit');
// Define the cfg variables
const trimContentAfter = "{{ cfg.trimContentAfter }}";
const instanceFullBaseUrl = "{{ cfg.instance.fullBaseUrl }}";
let questionText = questionContent.length > trimContentAfter ? questionContent.substring(0, trimContentAfter) + '…' : questionContent;
let answerText = answerContent.length > trimContentAfter ? answerContent.substring(0, trimContentAfter) + '…' : answerContent;
let questionUrl = `${instanceFullBaseUrl}/q/${questionId}/`;
let encodedContent = encodeURI(`${questionText} — ${answerText} ${questionUrl}`);
// Set up the shareOnFediverse function
submitBtn.addEventListener('click', function() {
shareOnFediverse(questionId, encodedContent);
});
});
document.addEventListener('DOMContentLoaded', function () {
const collapseElements = document.querySelectorAll('.collapse.question-cw');
const toggleButtons = document.querySelectorAll('.cw-btn');
const cwTexts = document.querySelectorAll('.cw-text');
collapseElements.forEach(function (collapseElement, index) {
let toggleButton = toggleButtons[index];
let cwText = cwTexts[index];
let buttonText = toggleButton.querySelector('.cw-btn-text');
let buttonCharsText = toggleButton.querySelector('.cw-btn-chars');
collapseElement.addEventListener('show.bs.collapse', function () {
buttonText.textContent = "{{ _('Hide content') }}";
buttonCharsText.classList.add('d-none');
cwText.classList.remove('text-center');
cwText.classList.remove('fw-bold');
});
collapseElement.addEventListener('hide.bs.collapse', function () {
buttonText.textContent = "{{ _('Show content') }}";
buttonCharsText.classList.remove('d-none');
cwText.classList.add('text-center');
cwText.classList.add('fw-bold');
});
});
});
function copy(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();
};
function shareOnFediverse(questionId, contentToShare) {
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
const instanceDomain = document.getElementById(`fediInstance`).value.trim();
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
window.open(shareUrl, '_blank');
}
</script>
<script>
document.getElementById('question-form').reset();
function initTooltips() {
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.detail.target.id != "question-count") {
document.getElementById('ask-btn').setAttribute('disabled', true);
}
});
initTooltips();
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);
@ -295,8 +281,12 @@
let targetElementId = event.detail.target.id;
if (targetElementId != "question-count") {
// WARNING: HACK
// we use this hack to avoid triggering the event listener twice when making a request to api.returnToInbox
if (document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.returntoinbox === "") {
// we use this hack to avoid triggering the event listener twice when making a request to api.returnToInbox and api.(un)pinQuestion
if (
(document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.deletetarget === "")
||
document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.deletetarget === ""
) {
targetElementId = event.detail.requestConfig.elt.dataset.target;
document.getElementById(targetElementId).outerHTML = '';
}

View file

@ -0,0 +1,10 @@
<tr id="emoji-{{emoji.name}}" class="emoji-row">
<td><img src="/{{ emoji.image }}" width="28" height="28" class="emoji"></td>
<td>{{ emoji.name }}</td>
<td><code>{{ emoji.relative_path }}</code></td>
<td>
<div id="actions">
<button class="btn btn-sm btn-outline-danger" hx-target="#emoji-{{emoji.name}}" hx-delete="{{ url_for('api.deleteEmoji', emoji_name=emoji.relative_path) }}"><i class="bi bi-trash me-1"></i> {{ _('Delete') }}</button>
</div>
</td>
</tr>

View file

@ -0,0 +1,19 @@
<tr id="pack-{{pack.name}}" class="emoji-pack-row">
<td><img src="/{{ pack.preview_image }}" width="28" height="28" class="emoji"></td>
<td><a data-bs-toggle="collapse" href="#pack-collapse-{{pack.name}}" class="text-decoration-none" title="{{ _('Click to see all emojis in this pack') }}">{{ pack.name }} <i class="bi bi-chevron-down small ms-1"></i></a></td>
{% if json_pack %}
<td>{{ pack.website }}</td>
<td>{{ formatRelativeTime(pack.exportedAt) }}</td>
{% endif %}
<td><code>{{ pack.relative_path }}</code></td>
<td>
<div id="actions">
<button class="btn btn-sm btn-outline-danger" hx-target="#pack-{{pack.name}}" hx-delete="{{ url_for('api.deleteEmojiPack', pack_name=pack.name) }}"><i class="bi bi-trash me-1"></i> {{ _('Delete') }}</button>
</div>
</td>
</tr>
<tr class="collapse" id="pack-collapse-{{pack.name}}" data-dontshowtoast hx-get="{{ url_for('api.getEmojiPacks', index=index) }}" hx-trigger="intersect" hx-target="#{{ pack.name }}-td">
<td class="border-0"></td>
<td class="border-0" id="{{ pack.name }}-td">
</td>
</tr>

View file

@ -0,0 +1,8 @@
<div class="form-group sticky-bottom bg-body py-3 mt-2 d-flex flex-column flex-lg-row z-1">
<button type="submit" class="btn btn-primary" id="saveConfig">
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
<i class="bi bi-floppy me-1"></i>
{{ _('Save') }}
</button>
</div>

View file

@ -0,0 +1,26 @@
<div class="mt-5 mb-sm-2 mb-md-5 col-lg-{% if class %}{{ class }}{% elif cfg.style.infoBoxLayout == 'row' %}10{% else %}8{% endif %} m-auto{% if not class and cfg.style.infoBoxLayout == 'row' %} d-lg-flex justify-content-between gap-2{% endif %}" id="description">
<div class="col-lg-6 m-auto">
<h1 class="text-center fw-bold" id="title">{{ cfg.instance.title }}</h1>
{% autoescape off %}
<h2 class="h5 text-center fw-light" id="desc">{{ cfg.instance.description | render_markdown }}</h2>
{% endautoescape %}
</div>
{% if len(cfg.instance.rules) > 0 %}
<div class="m-auto col-lg-{% if rules_class %}{{ rules_class }}{% elif cfg.style.infoBoxLayout == 'row' %}5{% else %}8{% endif %}">
<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" id="rules-content">{{ cfg.instance.rules | render_markdown }}</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,30 @@
<div class="col-lg-{% if combined %}3{% else %}6{% endif %}{% if not combined %} m-auto{% endif %} {{ class }}" id="description">
<div class="sticky-md-top pt-3">
<div class="card">
<div class="card-body">
<div class="markdown-content fw-buttons" id="desc">
<h1 class="fs-4">{{ cfg.instance.title }}</h1>
{{ cfg.instance.description | render_markdown }}
</div>
</div>
</div>
{% if cfg.instance.rules %}
<div class="mt-3">
<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">
<div class="accordion-body">
<div class="markdown-content" id="rules-content">{{ cfg.instance.rules | render_markdown }}</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,90 @@
{% include 'snippets/layout/description/normal.html' %}
<div class="row">
<div class="col-lg-{% if combined %}4{% else %}6{% endif %} col-xxl-3{% if not combined %} m-auto{% endif %}">
<div class="mb-5 sticky-md-top">
{% if cfg.lockInbox == false %}
<br>
<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" hx-disabled-elt="#ask-btn">
<div class="collapse" id="cw-collapse">
<div class="form-floating mb-2">
<input class="form-control" type="text" name="cw" id="cw" placeholder="{{ _('Content warning') }}">
<label for="cw">{{ _('Content warning') }}</label>
</div>
</div>
<div class="form-floating mb-2">
<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">
<textarea maxlength="{{ cfg.charLimit }}" class="form-control border-bottom-0 rounded-bottom-0" style="height: 100px;" required name="question" id="question" placeholder="{{ _('Write your question...') }}"></textarea>
<label for="question">{{ _('Write your question...') }}</label>
</div>
{% with includeCharLimit = True %}
{% include 'snippets/q-input-footer.html' %}
{% endwith %}
{% if cfg.antispam.enabled %}
{% if cfg.antispam.type == 'basic' %}
<div class="form-group mb-2">
{#- how am i supposed to use babel here #}
<label for="antispam">Anti-spam: please enter the word <code class="text-uppercase">{{ getRandomWord() }}</code> in lowercase</label>
<input class="form-control" type="text" required name="antispam" id="antispam" autocomplete="off">
</div>
{% elif cfg.antispam.type == 'recaptcha' %}
<div class="form-group mb-2">
{% if cfg.antispam.recaptcha.sitekey %}
<div class="g-recaptcha" data-sitekey="{{ cfg.antispam.recaptcha.sitekey }}"></div>
{% else %}
<div class="alert alert-warning" role="alert"><b>{{ _('Warning:') }}</b> {{ _('reCAPTCHA site key is not set!') }}</div>
{% endif %}
</div>
{% elif cfg.antispam.type == 'turnstile' %}
<div class="cf-turnstile" data-sitekey="{{ cfg.antispam.turnstile.sitekey }}"></div>
{% elif cfg.antispam.type == 'frc' %}
<div class="frc-captcha" data-sitekey="{{ cfg.antispam.frc.sitekey }}"></div>
{% else %}
<div class="alert alert-warning"><b>{{ _('Warning:') }}</b> {{ _('antispam type is not set!') }}</div>
{% endif %}
{% endif %}
<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.6.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-lg-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>
</div>
</form>
<div id="response-container"></div>
{% else %}
<br>
<h2 class="text-center">{{ _('New questions cannot be asked right now.') }}</h2>
{% endif %}
</div>
</div>
{% if combined %}
<div class="col-lg-8 col-xxl-9">
{% if cfg.showQuestionCount == true %}
<h3 class="fs-4">{{ totalQuestionCount }} <span class="fw-light">{{ _('question(s)') }}</span></h3>
{% endif %}
<div id="top-response-container"></div>
<div id="questions-container">
{% include 'snippets/layout/questions_list.html' %}
</div>
</div>
{% endif %}
</div>

View file

@ -0,0 +1,81 @@
<div class="row">
{% include 'snippets/layout/description/retrospring.html' %}
<div class="col-lg-9{% if not combined %} m-auto{% endif %}">
<div class="pt-3">
{% if not cfg.lockInbox %}
<div class="mb-4 card">
<div class="card-header d-flex justify-content-between align-items-center">{{ _('Ask a question') }}</div>
<div class="card-body">
<form class="d-lg-block" hx-post="{{ url_for('api.addQuestion') }}" id="question-form" hx-target="#response-container" hx-swap="none" hx-disabled-elt="#ask-btn">
<div class="collapse" id="cw-collapse">
<div class="form-group mb-2">
<input class="form-control" type="text" name="cw" id="cw" placeholder="{{ _('Content warning') }}">
<label for="cw" class="visually-hidden">{{ _('Content warning') }}</label>
</div>
</div>
<div class="form-group mb-2">
<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" class="visually-hidden">{{ _('Name') }} {% if cfg.allowAnonQuestions == true %}{{ _('(optional)') }}{% else %}{{ _('(required)') }}{% endif %}</label>
</div>
<div class="form-group">
<textarea maxlength="{{ cfg.charLimit }}" class="form-control border-bottom-0 rounded-bottom-0" required name="question" id="question" placeholder="{{ _('Write your question here...') }}"></textarea>
<label for="question" class="visually-hidden">{{ _('Write your question here...') }}</label>
</div>
{% with includeCharLimit = True %}
{% include 'snippets/q-input-footer.html' %}
{% endwith %}
{% if cfg.antispam.enabled %}
{% if cfg.antispam.type == 'basic' %}
<div class="form-group mb-2">
{#- skipping babel here for now #}
<label for="antispam">Anti-spam: please enter the word <code class="text-uppercase">{{ getRandomWord() }}</code> in lowercase</label>
<input class="form-control" type="text" required name="antispam" id="antispam" autocomplete="off">
</div>
{% elif cfg.antispam.type == 'recaptcha' %}
<div class="form-group mb-2">
{% if cfg.antispam.recaptcha.sitekey %}
<div class="g-recaptcha" data-sitekey="{{ cfg.antispam.recaptcha.sitekey }}"></div>
{% else %}
<div class="alert alert-warning" role="alert"><b>{{ _('Warning:') }}</b> {{ _('reCAPTCHA site key is not set!') }}</div>
{% endif %}
</div>
{% elif cfg.antispam.type == 'turnstile' %}
<div class="cf-turnstile" data-sitekey="{{ cfg.antispam.turnstile.sitekey }}"></div>
{% elif cfg.antispam.type == 'frc' %}
<div class="frc-captcha" data-sitekey="{{ cfg.antispam.frc.sitekey }}"></div>
{% else %}
<div class="alert alert-warning"><b>{{ _('Warning:') }}</b> {{ _('antispam type is not set!') }}</div>
{% endif %}
{% endif %}
<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.6.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-2" 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>
</div>
</form>
<div id="response-container"></div>
</div>
</div>
{% else %}
<h2 class="text-center">{{ _('New questions cannot be asked right now.') }}</h2>
{% endif %}
</div>
{% include 'snippets/layout/questions_list.html' %}
</div>
</div>

View file

@ -0,0 +1,16 @@
{% for item in combined %}
{% with question=item.question, answers=item.answers, showViewQuestionBtn=True, multipleAnswers=True %}
{% include 'snippets/layout/question_card.html' %}
{% endwith %}
{% endfor %}
{% if combined|length == per_page %}
<div class="spinner spinner-border mx-auto d-block my-2"
hx-trigger="intersect"
hx-get="{{ url_for('api.load_more_questions', page=page+1) }}"
hx-target="#question-list"
hx-swap="beforeend"
hx-on::after-request="this.remove(); initTooltips()">
<span class="visually-hidden">Loading...</span>
</div>
{% endif %}

View file

@ -0,0 +1,229 @@
{% if multipleAnswers %}
{% for answer in answers %}
<div class="card mt-3 mb-3{% if question.pinned %} border-2{% endif %}" id="question-{{ question.id }}">
<div class="card-header{% if question.pinned %} border-2{% endif %}">
<div class="d-flex justify-content-between align-items-center flex-wrap text-break">
<h3 class="{% if cfg.style.cardStyle == 'compact' %}mt-1 mb-0{% else %}my-1{% endif %} h5 card-title markdown-content w-50">
{% if question.from_who %}
{{ render_markdown(question.from_who, fromWho=True) }}
{% 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 %}
</h3>
<h3 class="h6 card-subtitle mt-1 fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime('%B %d, %Y %H:%M') }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h3>
</div>
{% with question=question %}
{% include 'snippets/q-card-text.html' %}
{% endwith %}
</div>
<div class="card-body markdown-content {% if cfg.style.cardStyle == 'compact' %}pb-0 pt-2 mt-1{% endif %}" id="a-{{ question.id }}-content">
{% if answer.cw %}
<div class="text-center mb-2 fw-bold cw-text markdown-content">{{ answer.cw | render_markdown }}</div>
<div class="collapse question-cw markdown-content" id="answer-cw-{{ answer.id }}">
{{ answer.content | render_markdown }}
</div>
<button class="z-0 cw-btn btn btn-sm btn-secondary shadow text-center w-100 sticky-bottom" type="button" data-bs-toggle="collapse" data-bs-target="#answer-cw-{{ answer.id }}" aria-expanded="false" aria-controls="answer-cw-{{ answer.id }}">
<span class="fw-medium cw-btn-text">{{ _('Show content') }}</span>
<span class="text-body-secondary cw-btn-chars">({{ _("{} characters").format(len(answer.content)) }})</span>
</button>
{% else %}
{{ answer.content | render_markdown }}
{% endif %}
</div>
<div class="card-footer {% if cfg.style.cardStyle == 'compact' %}border-0 bg-transparent{% endif %} py-0 ps-3 pe-1 text-body-secondary d-flex justify-content-between align-items-center{% if question.pinned %} border-2{% endif %}">
<div>
{% if cfg.username %}
<span class="text-body">{{ cfg.username }}</span><span> · </span>
{% endif %}
<span class="fs-6" data-bs-toggle="tooltip" data-bs-title="{{ answer.creation_date.strftime("%B %d, %Y %H:%M") }}">{{ formatRelativeTime(str(answer.creation_date)) }}</span>
{% if question.pinned %}
<span class="ms-1 fw-medium"><i class="bi bi-pin"></i> {{ _('Pinned') }}</span>
{% endif %}
</div>
<div class="d-flex align-items-center">
{% if showViewQuestionBtn %}
<a href="{{ url_for('viewQuestion', question_id=question.id) }}" class="btn btn-basic pt-2 pb-2 text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ _('View question') }}" aria-label="View question"><i class="bi bi-box-arrow-up-right"></i></a>
{% endif %}
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ _('Share question') }}"><i class="bi bi-share"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copyFull(`{{ trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter) }} {{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id) }}`)"><i class="bi bi-copy me-1"></i> {{ _('Copy to clipboard') }}</button></li>
<li>
<button class="dropdown-item d-flex align-items-center gap-1" data-bs-toggle="modal" data-q-id="{{ question.id }}" data-bs-target="#question-modal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64" class="me-1">
<path fill="currentColor" d="M12.088 23.868a6.734 6.732 0 0 1-2.88 2.866L25.02 42.602l3.812-1.93Zm20.857 20.93-3.812 1.932 8.012 8.04a6.734 6.732 0 0 1 2.88-2.866z"/>
<path fill="currentColor" d="m51.24 30.147-8.952 4.535.66 4.22 10.128-5.131a6.734 6.732 0 0 1-1.837-3.624Zm-14.15 7.168L15.926 48.038a6.734 6.732 0 0 1 1.837 3.624l19.989-10.127z"/>
<path fill="currentColor" d="M30.284 10.9 20.071 30.833l3.016 3.027L33.9 12.755a6.734 6.732 0 0 1-3.616-1.854zm-12.87 25.117-5.172 10.095a6.734 6.732 0 0 1 3.615 1.855l4.573-8.925z"/>
<path fill="currentColor" d="M9.12 26.778a6.734 6.732 0 0 1-3.364.703 6.734 6.732 0 0 1-.65-.068l3.02 19.316a6.734 6.732 0 0 1 3.365-.703 6.734 6.732 0 0 1 .65.068z"/>
<path fill="currentColor" d="M17.779 51.758a6.734 6.732 0 0 1 .07 1.356 6.734 6.732 0 0 1-.71 2.656l19.318 3.099a6.734 6.732 0 0 1-.07-1.356 6.734 6.732 0 0 1 .71-2.656Z"/>
<path fill="currentColor" d="m53.144 33.841-8.917 17.402a6.734 6.732 0 0 1 3.617 1.855l8.916-17.402a6.734 6.732 0 0 1-3.616-1.855z"/>
<path fill="currentColor" d="M40.983 9.229a6.734 6.732 0 0 1-2.88 2.866L51.91 25.953a6.734 6.732 0 0 1 2.88-2.867z"/>
<path fill="currentColor" d="M28.38 7.206 10.922 16.05a6.734 6.732 0 0 1 1.837 3.624l17.456-8.844a6.734 6.732 0 0 1-1.837-3.624Z"/>
<path fill="currentColor" d="M38.07 12.111a6.734 6.732 0 0 1-3.42.731 6.734 6.732 0 0 1-.589-.062l1.546 9.898 4.22.677zm-1.564 16.322 3.656 23.402a6.734 6.732 0 0 1 3.315-.678 6.734 6.732 0 0 1 .705.077L40.726 29.11Z"/>
<path fill="currentColor" d="M12.772 19.748a6.734 6.732 0 0 1 .075 1.377 6.734 6.732 0 0 1-.7 2.637l9.909 1.59 1.947-3.801zm16.984 2.726-1.948 3.803 23.413 3.759a6.734 6.732 0 0 1-.068-1.341 6.734 6.732 0 0 1 .718-2.67z"/>
<circle fill="currentColor" cx="35.017" cy="6.12" r="6.12"/>
<circle fill="currentColor" cx="57.878" cy="29.062" r="6.12"/>
<circle fill="currentColor" cx="43.111" cy="57.88" r="6.12"/>
<circle fill="currentColor" cx="11.124" cy="52.749" r="6.12"/>
<circle fill="currentColor" cx="6.122" cy="20.759" r="6.12"/>
</svg>
{{ _('Share on Fediverse') }}
</button>
</li>
<li><a class="dropdown-item" target="_blank" href="https://twitter.com/intent/tweet?text={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}"><i class="bi bi-twitter me-1"></i> {{ _('Share on Twitter') }}</a></li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://bsky.app/intent/compose?text={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}">
<svg width="16" height="16" class="me-1" viewBox="0 0 568 501" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0535 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z" fill="black"/>
</svg>
{{ _('Share on Bluesky') }}
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter), safe='') }}&url={{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}&caption=&content={{ urllib.parse.quote(trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" class="me-1">
<path d="M6.27051 7.62976C8.86829 7.07312 10.816 4.76401 10.816 2H13.8463V7.15152H17.4826V10.7879H13.8463V16.2424C13.8463 16.7566 14.044 17.4493 14.7554 17.9091C15.2296 18.2156 16.2397 18.3671 17.7857 18.3636V22H13.5432C11.0329 22 8.99778 19.9649 8.99778 17.4545V10.7879H6.27051V7.62976Z"></path>
</svg>
{{ _('Share on Tumblr') }}
</a>
</li>
<li class="d-block d-lg-none"><button class="dropdown-item nativeShareBtn" onclick="nativeShare(`{{ trimContent(question.content, cfg.trimContentAfter) }}`, `{{ trimContent(answer.content, cfg.trimContentAfter) }}`, `{{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id) }}`)"><i class="bi bi-share me-1"></i> {{ _('Share on other apps...') }}</button>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ _('Miscellaneous menu') }}"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})"><i class="bi bi-copy me-1"></i> {{ _('Copy link') }}</button></li>
{% if not noManageBtns %}
{% if logged_in %}
{% if not question.pinned %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.pinQuestion', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none"><i class="bi bi-pin me-1"></i> {{ _('Pin') }}</button></li>
{% else %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.unpinQuestion', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none" data-deletetarget data-target="question-{{ question.id }}"><i class="bi bi-pin-angle me-1"></i> {{ _('Unpin') }}</button></li>
{% endif %}
<li><button class="bg-hover-danger text-danger dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none" data-deletetarget data-target="question-{{ question.id }}"><i class="bi bi-arrow-return-left me-1"></i> {{ _('Return to inbox') }}</button></li>
{# will implement later
{% if not cfg.noDeleteConfirm %}
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-modal"><i class="bi bi-trash me-1"></i> 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"><i class="bi bi-trash me-1"></i> Delete</button>
{% endif %}
#}
{% endif %}
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% endfor %}
{% else %}
<div class="card mt-3 mb-3{% if question.pinned %} border-2{% endif %}" id="question-{{ question.id }}">
<div class="position-relative">
<div class="card-header{% if question.pinned %} border-2{% endif %}">
<div class="d-flex justify-content-between align-items-center flex-wrap text-break">
<h3 class="{% if cfg.style.cardStyle == 'compact' %}mt-1 mb-0{% else %}my-1{% endif %} h5 card-title markdown-content w-50">
{% if question.from_who %}
{{ render_markdown(question.from_who, fromWho=True) }}
{% 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 %}
</h3>
<h3 class="h6 card-subtitle fw-light text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime('%B %d, %Y %H:%M') }}" data-bs-placement="top">{{ formatRelativeTime(str(question.creation_date)) }}</h3>
</div>
{% with question=question %}
{% include 'snippets/q-card-text.html' %}
{% endwith %}
</div>
</div>
<div class="card-body markdown-content {% if cfg.style.cardStyle == 'compact' %}pb-0 pt-2 mt-1{% endif %}" id="a-{{ question.id }}-content">
{% if answer.cw %}
<div class="text-center mb-2 fw-bold cw-text markdown-content">{{ answer.cw | render_markdown }}</div>
<div class="collapse question-cw markdown-content" id="answer-cw-{{ answer.id }}">
{{ answer.content | render_markdown }}
</div>
<button class="z-0 cw-btn btn btn-sm btn-secondary shadow text-center w-100 sticky-bottom" type="button" data-bs-toggle="collapse" data-bs-target="#answer-cw-{{ answer.id }}" aria-expanded="false" aria-controls="answer-cw-{{ answer.id }}">
<span class="fw-medium cw-btn-text">{{ _('Show content') }}</span>
<span class="text-body-secondary cw-btn-chars">({{ _("{} characters").format(len(answer.content)) }})</span>
</button>
{% else %}
{{ answer.content | render_markdown }}
{% endif %}
</div>
<div class="card-footer {% if cfg.style.cardStyle == 'compact' %}border-0 bg-transparent{% endif %} py-0 ps-3 pe-1 text-body-secondary d-flex justify-content-between align-items-center{% if question.pinned %} border-2{% endif %}">
<div>
<span class="fs-6" data-bs-toggle="tooltip" data-bs-title="{{ answer.creation_date.strftime("%B %d, %Y %H:%M") }}">{{ formatRelativeTime(str(answer.creation_date)) }}</span>
{% if question.pinned %}
<span class="ms-1"><i class="bi bi-pin"></i> <span class="fw-medium">{{ _('Pinned') }}</span></span>
{% endif %}
</div>
<div class="d-flex align-items-center">
{% if showViewQuestionBtn %}
<a href="{{ url_for('viewQuestion', question_id=question.id) }}" class="btn btn-basic pt-2 pb-2 text-body-secondary" data-bs-toggle="tooltip" data-bs-title="{{ _('View question') }}" aria-label="View question"><i class="bi bi-box-arrow-up-right"></i></a>
{% endif %}
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ _('Share question') }}"><i class="bi bi-share"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copyFull(`{{ trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter) }} {{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id) }}`)"><i class="bi bi-copy me-1"></i> {{ _('Copy to clipboard') }}</button></li>
<li>
<button class="dropdown-item d-flex align-items-center gap-1" data-bs-toggle="modal" data-q-id="{{ question.id }}" data-bs-target="#question-modal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64" class="me-1">
<path fill="currentColor" d="M12.088 23.868a6.734 6.732 0 0 1-2.88 2.866L25.02 42.602l3.812-1.93Zm20.857 20.93-3.812 1.932 8.012 8.04a6.734 6.732 0 0 1 2.88-2.866z"/>
<path fill="currentColor" d="m51.24 30.147-8.952 4.535.66 4.22 10.128-5.131a6.734 6.732 0 0 1-1.837-3.624Zm-14.15 7.168L15.926 48.038a6.734 6.732 0 0 1 1.837 3.624l19.989-10.127z"/>
<path fill="currentColor" d="M30.284 10.9 20.071 30.833l3.016 3.027L33.9 12.755a6.734 6.732 0 0 1-3.616-1.854zm-12.87 25.117-5.172 10.095a6.734 6.732 0 0 1 3.615 1.855l4.573-8.925z"/>
<path fill="currentColor" d="M9.12 26.778a6.734 6.732 0 0 1-3.364.703 6.734 6.732 0 0 1-.65-.068l3.02 19.316a6.734 6.732 0 0 1 3.365-.703 6.734 6.732 0 0 1 .65.068z"/>
<path fill="currentColor" d="M17.779 51.758a6.734 6.732 0 0 1 .07 1.356 6.734 6.732 0 0 1-.71 2.656l19.318 3.099a6.734 6.732 0 0 1-.07-1.356 6.734 6.732 0 0 1 .71-2.656Z"/>
<path fill="currentColor" d="m53.144 33.841-8.917 17.402a6.734 6.732 0 0 1 3.617 1.855l8.916-17.402a6.734 6.732 0 0 1-3.616-1.855z"/>
<path fill="currentColor" d="M40.983 9.229a6.734 6.732 0 0 1-2.88 2.866L51.91 25.953a6.734 6.732 0 0 1 2.88-2.867z"/>
<path fill="currentColor" d="M28.38 7.206 10.922 16.05a6.734 6.732 0 0 1 1.837 3.624l17.456-8.844a6.734 6.732 0 0 1-1.837-3.624Z"/>
<path fill="currentColor" d="M38.07 12.111a6.734 6.732 0 0 1-3.42.731 6.734 6.732 0 0 1-.589-.062l1.546 9.898 4.22.677zm-1.564 16.322 3.656 23.402a6.734 6.732 0 0 1 3.315-.678 6.734 6.732 0 0 1 .705.077L40.726 29.11Z"/>
<path fill="currentColor" d="M12.772 19.748a6.734 6.732 0 0 1 .075 1.377 6.734 6.732 0 0 1-.7 2.637l9.909 1.59 1.947-3.801zm16.984 2.726-1.948 3.803 23.413 3.759a6.734 6.732 0 0 1-.068-1.341 6.734 6.732 0 0 1 .718-2.67z"/>
<circle fill="currentColor" cx="35.017" cy="6.12" r="6.12"/>
<circle fill="currentColor" cx="57.878" cy="29.062" r="6.12"/>
<circle fill="currentColor" cx="43.111" cy="57.88" r="6.12"/>
<circle fill="currentColor" cx="11.124" cy="52.749" r="6.12"/>
<circle fill="currentColor" cx="6.122" cy="20.759" r="6.12"/>
</svg>
{{ _('Share on Fediverse') }}
</button>
</li>
<li><a class="dropdown-item" target="_blank" href="https://twitter.com/intent/tweet?text={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}"><i class="bi bi-twitter me-1"></i> {{ _('Share on Twitter') }}</a></li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://bsky.app/intent/compose?text={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter) + ' — ' + trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}">
<svg width="16" height="16" class="me-1" viewBox="0 0 568 501" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0535 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z" fill="black"/>
</svg>
{{ _('Share on Bluesky') }}
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" target="_blank" href="https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title={{ urllib.parse.quote(trimContent(question.content, cfg.trimContentAfter), safe='') }}&url={{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}&caption=&content={{ urllib.parse.quote(trimContent(answer.content, cfg.trimContentAfter),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" class="me-1">
<path d="M6.27051 7.62976C8.86829 7.07312 10.816 4.76401 10.816 2H13.8463V7.15152H17.4826V10.7879H13.8463V16.2424C13.8463 16.7566 14.044 17.4493 14.7554 17.9091C15.2296 18.2156 16.2397 18.3671 17.7857 18.3636V22H13.5432C11.0329 22 8.99778 19.9649 8.99778 17.4545V10.7879H6.27051V7.62976Z"></path>
</svg>
{{ _('Share on Tumblr') }}
</a>
</li>
<li class="d-block d-lg-none"><button class="dropdown-item nativeShareBtn" onclick="nativeShare(`{{ trimContent(question.content, cfg.trimContentAfter) }}`, `{{ trimContent(answer.content, cfg.trimContentAfter) }}`, `{{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id) }}`)"><i class="bi bi-share me-1"></i> {{ _('Share on other apps...') }}</button>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false" aria-label="{{ _('Miscellaneous menu') }}"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})"><i class="bi bi-copy me-1"></i> {{ _('Copy link') }}</button></li>
{% if not noManageBtns %}
{% if logged_in %}
{% if not question.pinned %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.pinQuestion', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none"><i class="bi bi-pin me-1"></i> {{ _('Pin') }}</button></li>
{% else %}
<li><button class="dropdown-item" hx-post="{{ url_for('api.unpinQuestion', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none"><i class="bi bi-pin-angle me-1"></i> {{ _('Unpin') }}</button></li>
{% endif %}
<li><button class="bg-hover-danger text-danger dropdown-item" hx-post="{{ url_for('api.returnToInbox', question_id=question.id) }}" hx-target="#top-response-container" hx-swap="none" data-deletetarget data-target="question-{{ question.id }}"><i class="bi bi-arrow-return-left me-1"></i> {{ _('Return to inbox') }}</button></li>
{% endif %}
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% endif %}

View file

@ -0,0 +1,40 @@
<div id="question-list">
{% for item in combined %}
{% with question=item.question, answers=item.answers, showViewQuestionBtn=True, multipleAnswers=True %}
{% include 'snippets/layout/question_card.html' %}
{% endwith %}
{% endfor %}
</div>
{% if combined|length == per_page %}
<div class="spinner spinner-border mx-auto d-block my-2"
hx-trigger="intersect"
hx-get="{{ url_for('api.load_more_questions', page=page+1) }}"
hx-target="#question-list"
hx-swap="beforeend"
hx-on::after-request="this.remove(); initTooltips()">
<span class="visually-hidden">Loading...</span>
</div>
{% endif %}
<div class="modal fade" id="question-modal" tabindex="-1" aria-labelledby="q-modal-label" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header border-0">
<h1 class="modal-title fs-5 fw-normal" id="q-modal-label">{{ _('Share on Fediverse') }}</h1>
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-dismiss="modal" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body py-0">
<div class="form-group mb-3">
<label for="fediInstance" class="form-label">{{ _('Fediverse instance domain:') }}</label>
<input type="text" id="fediInstance" name="fediInstance" class="form-control">
</div>
</div>
<div class="modal-footer pt-1 border-0 flex-column flex-sm-row justify-content-sm-between align-items-stretch align-items-sm-center">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" id="q-modal-submit">{{ _('Share') }}</button>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,33 @@
{% if mobileNav %}
<style>
@media screen and (max-width: 575px) {
.mobile-nav .nav-link {
width: 22vw;
}
.mobile-nav .nav-link i {
font-size: 1.5em !important;
}
}
</style>
{% endif %}
<li class="nav-item d-flex align-items-center">
<a class="nav-link {{ homeLink }}{% if mobileNav %} py-2 d-flex flex-column justify-content-center align-items-center{% endif %}" id="home-link" href="{{ url_for('index') }}"{% if cfg.style.navIconsOnly %} title="{{ _('Home') }}"{% endif %}>
{% if cfg.style.navIcons %}<i class="bi bi-house-door fs-mob-5{% if cfg.style.navIconsOnly %} fs-5{% endif %}"></i>{% endif %}
{% if not cfg.style.navIconsOnly and not mobileNav %}<span{% if cfg.style.navIcons %} class="{% if not mobileNav %}d-none ms-1 {% endif %}d-lg-inline"{% endif %}>{{ _('Home') }}</span>{% endif %}
</a>
</li>
{% if logged_in %}
<li class="nav-item d-flex align-items-center position-relative">
<a class="nav-link {{ inboxLink }}{% if cfg.style.navIcons and not mobileNav %} ms-1{% endif %} {% if mobileNav %} py-2 d-flex flex-column justify-content-center align-items-center{% endif %}" id="inbox-link" href="{{ url_for('inbox') }}"{% if cfg.style.navIconsOnly %} title="{{ _('Inbox') }}"{% endif %}>
{% if unreadQuestionCount > 0 %}<span class="position-absolute top-0 start-100 badge text-bg-primary rounded-circle" style="transform: translate(-80%,-30%) !important">{{ unreadQuestionCount }} <span class="visually-hidden">{{ _('unanswered questions') }}</span></span>{% endif %}
{% if cfg.style.navIcons %}<i class="bi bi-inbox fs-mob-5{% if cfg.style.navIconsOnly %} fs-5{% endif %}"></i>{% endif %}
{% if not cfg.style.navIconsOnly and not mobileNav %}<span{% if cfg.style.navIcons %} class="{% if not mobileNav %}d-none ms-1 {% endif %}d-lg-inline"{% endif %}>{{ _('Inbox') }}</span>{% endif %}
</a>
</li>
<li class="nav-item d-flex align-items-center">
<a class="nav-link {{ adminLink }}{% if cfg.style.navIcons and not mobileNav %} ms-1{% endif %}{% if mobileNav %} py-2 d-flex flex-column justify-content-center align-items-center{% endif %}" id="admin-link" href="{{ url_for('admin.information') }}"{% if cfg.style.navIconsOnly %} title="{{ _('Admin') }}"{% endif %}>
{% if cfg.style.navIcons %}<i class="bi bi-gear fs-mob-5{% if cfg.style.navIconsOnly %} fs-5{% endif %}"></i>{% endif %}
{% if not cfg.style.navIconsOnly and not mobileNav %}<span{% if cfg.style.navIcons %} class="{% if not mobileNav %}d-none ms-1 {% endif %}d-lg-inline"{% endif %}>{{ _('Admin') }}</span>{% endif %}
</a>
</li>
{% endif %}

View file

@ -0,0 +1,14 @@
<div class="card-text markdown-content{% if not question.cw %} question-{{ question.id }}{% endif %}">
{% if question.cw %}
<p class="text-center mb-2 fw-bold cw-text">{{ question.cw }}</p>
<div class="collapse question-cw question-{{ question.id }}" id="question-cw-{{ question.id }}">
{{ question.content | render_markdown }}
</div>
<button class="z-0 cw-btn btn btn-sm btn-secondary shadow text-center w-100 sticky-bottom" type="button" data-bs-toggle="collapse" data-bs-target="#question-cw-{{ question.id }}" aria-expanded="false" aria-controls="question-cw-{{ question.id }}">
<span class="fw-medium cw-btn-text">{{ _('Show content') }}</span>
<span class="text-body-secondary cw-btn-chars">({{ _("{} characters").format(len(question.content)) }})</span>
</button>
{% else %}
{{ question.content | render_markdown }}
{% endif %}
</div>

View file

@ -0,0 +1,20 @@
<div class="rounded-bottom border border-top-0 p-2 d-flex align-items-center justify-content-between" style="background-color: var(--bs-body-bg);">
<div class="d-flex align-items-center gap-2">
<button class="ms-1 btn btn-secondary" type="button" title="{{ _('Content warning') }}" data-bs-toggle="collapse" data-bs-target="{% if customCwTarget %}{{ customCwTarget}}{% else %}#cw-collapse{% endif %}" aria-expanded="false" aria-controls="cw-collapse">
<i class="bi bi-exclamation-triangle"></i>
</button>
{% if not noEmojiPicker %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle no-arrow" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false" title="{{ _('Add emoji') }}">
<i class="bi bi-emoji-smile"></i>
</button>
<div class="dropdown-menu p-0">
<div id="{% if customEmojiPickerId %}{{ customEmojiPickerId }}{% else %}emoji-picker{% endif %}" class="emoji-picker"></div>
</div>
</div>
{% endif %}
</div>
{% if includeCharLimit %}
<span id="charCount" class="small" title="{{ _('Character limit') }}">{{ cfg.charLimit }}</span>
{% endif %}
</div>

View file

@ -1,112 +1,121 @@
{% extends 'base.html' %}
{% block title %}{{ trimContent(question.content, 30) }} - {{ trimContent(answer.content, 30) }}{% endblock %}
{% block title %}{{ trimContent(question.content, cfg.trimContentAfter) }} - {{ trimContent(answer.content, cfg.trimContentAfter) }}{% endblock %}
{% block additionalHeadItems %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
{% endblock %}
{% block content %}
<div class="row">
<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 flex-wrap text-break">
<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"></i> {{ cfg.anonName }}{% endif %}</h5>
<h6 class="card-subtitle fw-light text-body-secondary"data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime("%B %d, %Y %H:%M") }}">{{ formatRelativeTime(str(question.creation_date)) }}</h6>
</div>
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
</div>
<div class="card-body">
<div class="markdown-content">{{ answer.content | render_markdown }}</div>
</div>
<div class="card-footer pt-0 pb-0 ps-3 pe-1 text-body-secondary d-flex justify-content-between align-items-center">
<span class="fs-6" data-bs-toggle="tooltip" data-bs-title="{{ answer.creation_date.strftime('%B %d, %Y %H:%M') }}">{{ formatRelativeTime(str(answer.creation_date)) }}</span>
<div class="d-flex align-items-center">
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-share"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copyFull(`{{ trimContent(question.content, 30) + ' — ' + trimContent(answer.content, 30) }} {{ cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id) }}`)"><i class="bi bi-copy me-1"></i> Copy to clipboard</button></li>
<li>
<button class="dropdown-item d-flex align-items-center gap-1" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 64 64" class="me-1">
<path fill="currentColor" d="M12.088 23.868a6.734 6.732 0 0 1-2.88 2.866L25.02 42.602l3.812-1.93Zm20.857 20.93-3.812 1.932 8.012 8.04a6.734 6.732 0 0 1 2.88-2.866z"/>
<path fill="currentColor" d="m51.24 30.147-8.952 4.535.66 4.22 10.128-5.131a6.734 6.732 0 0 1-1.837-3.624Zm-14.15 7.168L15.926 48.038a6.734 6.732 0 0 1 1.837 3.624l19.989-10.127z"/>
<path fill="currentColor" d="M30.284 10.9 20.071 30.833l3.016 3.027L33.9 12.755a6.734 6.732 0 0 1-3.616-1.854zm-12.87 25.117-5.172 10.095a6.734 6.732 0 0 1 3.615 1.855l4.573-8.925z"/>
<path fill="currentColor" d="M9.12 26.778a6.734 6.732 0 0 1-3.364.703 6.734 6.732 0 0 1-.65-.068l3.02 19.316a6.734 6.732 0 0 1 3.365-.703 6.734 6.732 0 0 1 .65.068z"/>
<path fill="currentColor" d="M17.779 51.758a6.734 6.732 0 0 1 .07 1.356 6.734 6.732 0 0 1-.71 2.656l19.318 3.099a6.734 6.732 0 0 1-.07-1.356 6.734 6.732 0 0 1 .71-2.656Z"/>
<path fill="currentColor" d="m53.144 33.841-8.917 17.402a6.734 6.732 0 0 1 3.617 1.855l8.916-17.402a6.734 6.732 0 0 1-3.616-1.855z"/>
<path fill="currentColor" d="M40.983 9.229a6.734 6.732 0 0 1-2.88 2.866L51.91 25.953a6.734 6.732 0 0 1 2.88-2.867z"/>
<path fill="currentColor" d="M28.38 7.206 10.922 16.05a6.734 6.732 0 0 1 1.837 3.624l17.456-8.844a6.734 6.732 0 0 1-1.837-3.624Z"/>
<path fill="currentColor" d="M38.07 12.111a6.734 6.732 0 0 1-3.42.731 6.734 6.732 0 0 1-.589-.062l1.546 9.898 4.22.677zm-1.564 16.322 3.656 23.402a6.734 6.732 0 0 1 3.315-.678 6.734 6.732 0 0 1 .705.077L40.726 29.11Z"/>
<path fill="currentColor" d="M12.772 19.748a6.734 6.732 0 0 1 .075 1.377 6.734 6.732 0 0 1-.7 2.637l9.909 1.59 1.947-3.801zm16.984 2.726-1.948 3.803 23.413 3.759a6.734 6.732 0 0 1-.068-1.341 6.734 6.732 0 0 1 .718-2.67z"/>
<circle fill="currentColor" cx="35.017" cy="6.12" r="6.12"/>
<circle fill="currentColor" cx="57.878" cy="29.062" r="6.12"/>
<circle fill="currentColor" cx="43.111" cy="57.88" r="6.12"/>
<circle fill="currentColor" cx="11.124" cy="52.749" r="6.12"/>
<circle fill="currentColor" cx="6.122" cy="20.759" r="6.12"/>
</svg>
Share on Fediverse
</button>
</li>
<li><a class="dropdown-item" href="https://twitter.com/intent/tweet?text={{ urllib.parse.quote(trimContent(question.content, 30) + ' — ' + trimContent(answer.content, 30),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + '/q/' + str(question.id),safe='') }}"><i class="bi bi-twitter me-1"></i> Share on Twitter</a></li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" href="https://bsky.app/intent/compose?text={{ urllib.parse.quote(trimContent(question.content, 30) + ' — ' + trimContent(answer.content, 30),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + url_for('viewQuestion',question_id=question.id), safe='') }}">
<svg width="16" height="16" class="me-1" viewBox="0 0 568 501" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 203.659 552.222 224.501C531.947 296.954 458.067 315.434 392.347 304.249C507.222 323.8 536.444 388.56 473.333 453.32C353.473 576.312 301.061 422.461 287.631 383.039C285.169 375.812 284.017 372.431 284 375.306C283.983 372.431 282.831 375.812 280.369 383.039C266.939 422.461 214.527 576.312 94.6667 453.32C31.5556 388.56 60.7778 323.8 175.653 304.249C109.933 315.434 36.0535 296.954 15.7778 224.501C9.94525 203.659 0 75.2916 0 57.9464C0 -28.9064 76.1345 -1.61183 123.121 33.6637Z" fill="black"/>
</svg>
Share on Bluesky
</a>
</li>
<li>
<a class="dropdown-item d-flex align-items-center gap-1" href="https://www.tumblr.com/widgets/share/tool?shareSource=legacy&posttype=text&title={{ urllib.parse.quote(trimContent(question.content, 30),safe='') }}&url={{ urllib.parse.quote(cfg.instance.fullBaseUrl + '/q/' + str(question.id),safe='') }}&caption=&content={{ urllib.parse.quote(trimContent(answer.content, 30),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + '/q/' + str(question.id),safe='') }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16" class="me-1">
<path d="M6.27051 7.62976C8.86829 7.07312 10.816 4.76401 10.816 2H13.8463V7.15152H17.4826V10.7879H13.8463V16.2424C13.8463 16.7566 14.044 17.4493 14.7554 17.9091C15.2296 18.2156 16.2397 18.3671 17.7857 18.3636V22H13.5432C11.0329 22 8.99778 19.9649 8.99778 17.4545V10.7879H6.27051V7.62976Z"></path>
</svg>
Share on Tumblr
</a>
</li>
</ul>
</div>
<div class="dropdown">
<button class="btn btn-basic pt-2 pb-2 no-arrow text-body-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"><i class="bi bi-three-dots"></i></button>
<ul class="dropdown-menu">
<li><button class="dropdown-item" onclick="copy({{ question.id }})"><i class="bi bi-copy me-1"></i> 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=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none"><i class="bi bi-arrow-return-left me-1"></i> Return to inbox</button></li>
{% endif %}
</ul>
</div>
</div>
</div>
</div>
{% with answers=answer, noManageBtns=True %}
{% include 'snippets/layout/question_card.html' %}
{% endwith %}
</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 fade" id="question-modal" tabindex="-1" aria-labelledby="q-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">Share on Fediverse</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div class="modal-header border-0">
<h1 class="modal-title fs-5 fw-normal" id="q-modal-label">{{ _('Share on Fediverse') }}</h1>
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-dismiss="modal" aria-label="Close"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body">
<div class="modal-body py-0">
<div class="form-group mb-3">
<label for="fediInstance">Fediverse instance domain:</label>
<input type="text" id="fediInstance-{{question.id}}" name="fediInstance" class="form-control">
<label for="fediInstance" class="form-label">{{ _('Fediverse instance domain:') }}</label>
<input type="text" id="fediInstance" name="fediInstance" class="form-control">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" onclick="shareOnFediverse('{{ question.id }}', `{{ urllib.parse.quote(trimContent(question.content, 30) + ' — ' + trimContent(answer.content, 30),safe='') }}%20{{ urllib.parse.quote(cfg.instance.fullBaseUrl + '/q/' + str(question.id) + '/',safe='') }}`)">Share</button>
<div class="modal-footer pt-1 border-0">
<button type="button" class="btn btn-outline-secondary flex-fill flex-lg-grow-0" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
<button type="button" class="btn btn-primary flex-fill flex-lg-grow-0" data-bs-dismiss="modal" id="q-modal-submit">{{ _('Share') }}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
<script>
const questionModal = document.getElementById('question-modal');
questionModal.addEventListener('show.bs.modal', event => {
// Button that triggered the modal
let button = event.relatedTarget;
// Extract info from data-bs-* attributes
let questionId = "{{ question.id }}";
let questionContent = document.querySelector(`.question-${questionId}`).innerText;
console.log(questionContent);
let answerContent = document.getElementById(`a-${questionId}-content`).innerText;
console.log(answerContent);
let submitBtn = document.getElementById('q-modal-submit');
// Define the cfg variables
const trimContentAfter = "{{ cfg.trimContentAfter }}";
const instanceFullBaseUrl = "{{ cfg.instance.fullBaseUrl }}";
let questionText = questionContent.length > trimContentAfter ? questionContent.substring(0, trimContentAfter) + '…' : questionContent;
let answerText = answerContent.length > trimContentAfter ? answerContent.substring(0, trimContentAfter) + '…' : answerContent;
let questionUrl = `${instanceFullBaseUrl}/q/${questionId}/`;
let encodedContent = encodeURI(`${questionText} — ${answerText} ${questionUrl}`);
// Set up the shareOnFediverse function
submitBtn.addEventListener('click', function() {
shareOnFediverse(questionId, encodedContent);
});
console.log(submitBtn);
});
document.addEventListener('DOMContentLoaded', function () {
const collapseElements = document.querySelectorAll('.collapse.question-cw');
const toggleButtons = document.querySelectorAll('.cw-btn');
const cwTexts = document.querySelectorAll('.cw-text');
collapseElements.forEach(function (collapseElement, index) {
let toggleButton = toggleButtons[index];
let cwText = cwTexts[index];
let buttonText = toggleButton.querySelector('.cw-btn-text');
let buttonCharsText = toggleButton.querySelector('.cw-btn-chars');
collapseElement.addEventListener('show.bs.collapse', function () {
buttonText.textContent = "{{ _('Hide content') }}";
buttonCharsText.classList.add('d-none');
cwText.classList.remove('text-center');
cwText.classList.remove('fw-bold');
});
collapseElement.addEventListener('hide.bs.collapse', function () {
buttonText.textContent = "{{ _('Show content') }}";
buttonCharsText.classList.remove('d-none');
cwText.classList.add('text-center');
cwText.classList.add('fw-bold');
});
});
});
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();
}
function shareOnFediverse(questionId, contentToShare) {
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
const instanceDomain = document.getElementById(`fediInstance`).value.trim();
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
window.open(shareUrl, '_blank');
@ -126,9 +135,16 @@ function shareOnFediverse(questionId, contentToShare) {
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;
let targetElementId = event.detail.target.id;
if (targetElementId != "question-count") {
if (document.getElementById(targetElementId) && targetElementId.includes("question-")) {
// WARNING: HACK
// we use this hack to avoid triggering the event listener twice when making a request to api.returnToInbox and api.(un)pinQuestion
if (
(document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.deletetarget === "")
||
document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.deletetarget === ""
) {
targetElementId = event.detail.requestConfig.elt.dataset.target;
document.getElementById(targetElementId).outerHTML = '';
}
if (msgType) {