Compare commits
No commits in common. "main" and "dev" have entirely different histories.
64 changed files with 1052 additions and 7291 deletions
|
@ -2,6 +2,6 @@ DB_HOST = 127.0.0.1
|
||||||
DB_NAME = catask
|
DB_NAME = catask
|
||||||
DB_USER =
|
DB_USER =
|
||||||
DB_PASS =
|
DB_PASS =
|
||||||
DB_PORT = 5432
|
DB_PORT = 3306
|
||||||
ADMIN_PASSWORD =
|
ADMIN_PASSWORD =
|
||||||
APP_SECRET =
|
APP_SECRET =
|
||||||
|
|
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -6,9 +6,3 @@ word_blacklist.txt
|
||||||
static/icons/favicon/*.*
|
static/icons/favicon/*.*
|
||||||
!static/icons/favicon/default
|
!static/icons/favicon/default
|
||||||
install.sh
|
install.sh
|
||||||
static/emojis/*
|
|
||||||
ip_blacklist.txt
|
|
||||||
ca*.zip
|
|
||||||
static/exports/*
|
|
||||||
*.log
|
|
||||||
exports.json
|
|
||||||
|
|
120
CHANGELOG.md
120
CHANGELOG.md
|
@ -1,123 +1,3 @@
|
||||||
## 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
|
## 1.5.6
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
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" ]
|
|
29
README.md
29
README.md
|
@ -1,16 +1,16 @@
|
||||||
#  CatAsk
|
#  CatAsk
|
||||||
|
|
||||||
a simple & easy to use Q&A software that makes answering questions easier
|
a work-in-progress minimal single-user q&a software
|
||||||
|
|
||||||
|
> [!NOTE]
|
||||||
|
> CatAsk is alpha software, therefore bugs are expected to happen
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
- PostgreSQL
|
- MySQL/MariaDB
|
||||||
- Python 3.10+ (3.12+ recommended)
|
- Python 3.10+ (3.12+ recommended)
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
Clone this repository: `git clone https://codeberg.org/catask-org/catask.git`
|
Clone this repository: `git clone https://git.gay/mst/catask.git`
|
||||||
|
|
||||||
### Docker
|
|
||||||
See [docker.md](./docker.md) for install instructions
|
|
||||||
|
|
||||||
### VPS-specific
|
### VPS-specific
|
||||||
Go into the cloned repository, create a virtual environment and activate it:
|
Go into the cloned repository, create a virtual environment and activate it:
|
||||||
|
@ -26,14 +26,6 @@ Go into the cloned repository, create a virtual environment and activate it:
|
||||||
After that, install required packages:
|
After that, install required packages:
|
||||||
```pip install -r requirements.txt```
|
```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
|
### Shared hosting-specific
|
||||||
If your shared hosting provider supports [WSGI](https://w.wiki/_vTN2), [FastCGI](https://w.wiki/9EeQ), or something similar, use it (technically any CGI protocol could work)
|
If your shared hosting provider supports [WSGI](https://w.wiki/_vTN2), [FastCGI](https://w.wiki/9EeQ), or something similar, use it (technically any CGI protocol could work)
|
||||||
|
|
||||||
|
@ -48,12 +40,12 @@ First, rename `.env.example` to `.env` and `config.example.json` to `config.json
|
||||||
`DB_NAME` - database name
|
`DB_NAME` - database name
|
||||||
`DB_USER` - database user
|
`DB_USER` - database user
|
||||||
`DB_PASS` - database password
|
`DB_PASS` - database password
|
||||||
`DB_PORT` - database port (usually 5432)
|
`DB_PORT` - database port (usually 3306)
|
||||||
`ADMIN_PASSWORD` - password to access admin panel
|
`ADMIN_PASSWORD` - password to access admin panel
|
||||||
`APP_SECRET` - application secret, generate one with this command: `python3 -c 'import secrets; print(secrets.token_hex())'`
|
`APP_SECRET` - application secret, generate one with this command: `python3 -c 'import secrets; print(secrets.token_hex())'`
|
||||||
|
|
||||||
### config.json
|
### config.json
|
||||||
Configure in Admin panel after installing
|
Configure in Admin panel after installing (located at `https://yourdomain.tld/admin/`)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -69,10 +61,11 @@ 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`)
|
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`
|
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
|
Runs on `127.0.0.1:8000` by default
|
||||||
|
|
||||||
### Caddy
|
### Caddy
|
||||||
This repository contains an example Caddyfile that runs CatAsk on catask.localhost by reverse proxying it to 127.0.0.1:5000, you can modify it as needed
|
This repository contains an example Caddyfile that runs CatAsk on catask.localhost by reverse proxying it to 127.0.0.1:5000, you can modify it as needed
|
||||||
## Updating
|
## Updating
|
||||||
For instructions with updating from one version to another, check [UPDATE.md](./UPDATE.md) file
|
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](./CHANGELOG.md) file for release notes
|
Check [CHANGELOG.md](https://git.gay/mst/catask/src/branch/main/CHANGELOG.md) file for release notes
|
||||||
|
|
171
UPDATE.md
171
UPDATE.md
|
@ -1,169 +1,4 @@
|
||||||
# Updating
|
## 1.4.x -> 1.5.0
|
||||||
|
|
||||||
## 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`
|
pull the update as usual: `git pull`
|
||||||
run `pip install -r requirements.txt` again to install newly required packages
|
run `pip install -r requirements.txt` again to install newly required packages
|
||||||
|
@ -173,7 +8,7 @@ make the following changes in your config.json file:
|
||||||
"instance": {
|
"instance": {
|
||||||
...
|
...
|
||||||
- "image": "/static/img/ca_screenshot.png",
|
- "image": "/static/img/ca_screenshot.png",
|
||||||
+ "image": "/static/icons/favicon/android-chrome-512x512.png",
|
+ "image": "/static/icons/favicon/android-chrome-192x192.png",
|
||||||
...
|
...
|
||||||
},
|
},
|
||||||
+ "style": {
|
+ "style": {
|
||||||
|
@ -185,7 +20,7 @@ make the following changes in your config.json file:
|
||||||
+ "tintColors": false,
|
+ "tintColors": false,
|
||||||
+ "infoBoxLayout": "column"
|
+ "infoBoxLayout": "column"
|
||||||
+ },
|
+ },
|
||||||
+ "trimContentAfter": "50",
|
+ "trimContentAfter": "50",
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
@ -1,2 +0,0 @@
|
||||||
[python: *.py]
|
|
||||||
[jinja2: **/templates/**.html]
|
|
|
@ -2,68 +2,24 @@
|
||||||
"instance": {
|
"instance": {
|
||||||
"title": "CatAsk",
|
"title": "CatAsk",
|
||||||
"description": "Ask me something!",
|
"description": "Ask me something!",
|
||||||
"image": "/static/icons/favicon/android-chrome-512x512.png",
|
"image": "/static/icons/favicon/apple-touch-icon.png",
|
||||||
"fullBaseUrl": "https://catask.localhost",
|
"fullBaseUrl": "https://catask.localhost",
|
||||||
"rules": ""
|
"rules": ""
|
||||||
},
|
|
||||||
"accessibility": {
|
|
||||||
"font": "default",
|
|
||||||
"userway": {
|
|
||||||
"enabled": false,
|
|
||||||
"account": ""
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"languages": {
|
|
||||||
"default": "en_US",
|
|
||||||
"allowChanging": true
|
|
||||||
},
|
},
|
||||||
"style": {
|
"style": {
|
||||||
"accentLight": "#6345d9",
|
"accentLight": "#6345d9",
|
||||||
"accentDark": "#7a63e3",
|
"accentDark": "#7259d9",
|
||||||
"bgLight": "#ffffff",
|
"bgLight": "#ffffff",
|
||||||
"bgDark": "#202020",
|
"bgDark": "#202020",
|
||||||
"navStyle": "pills",
|
"navStyle": "underline",
|
||||||
"tintColors": false,
|
"tintColors": false,
|
||||||
"infoBoxLayout": "row",
|
"infoBoxLayout": "column"
|
||||||
"homepageLayout": "catask",
|
|
||||||
"navIcons": true,
|
|
||||||
"navIconsOnly": false,
|
|
||||||
"customCss": "",
|
|
||||||
"useCustomCss": false,
|
|
||||||
"overrideBaseStyles": false,
|
|
||||||
"overrideCatAskStyles": false,
|
|
||||||
"cardStyle": "compact"
|
|
||||||
},
|
},
|
||||||
"antispam": {
|
"trimContentAfter": "50",
|
||||||
"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",
|
"charLimit": "512",
|
||||||
"anonName": "Anonymous",
|
"anonName": "Anonymous",
|
||||||
"lockInbox": false,
|
"lockInbox": false,
|
||||||
"allowAnonQuestions": true,
|
"allowAnonQuestions": true,
|
||||||
"showQuestionCount": true,
|
"showQuestionCount": false,
|
||||||
"noDeleteConfirm": false
|
"noDeleteConfirm": false
|
||||||
}
|
}
|
||||||
|
|
17
constants.py
17
constants.py
|
@ -2,22 +2,9 @@ from pathlib import Path
|
||||||
|
|
||||||
antiSpamFile = 'wordlist.txt'
|
antiSpamFile = 'wordlist.txt'
|
||||||
blacklistFile = 'word_blacklist.txt'
|
blacklistFile = 'word_blacklist.txt'
|
||||||
# reserved for 1.7.0 or later
|
|
||||||
# ipBlacklistFile = 'ip_blacklist.txt'
|
|
||||||
configFile = 'config.json'
|
configFile = 'config.json'
|
||||||
exportsFile = 'exports.json'
|
|
||||||
faviconDir = Path.cwd() / 'static' / 'icons' / 'favicon'
|
faviconDir = Path.cwd() / 'static' / 'icons' / 'favicon'
|
||||||
tempDir = Path.cwd() / 'static' / 'temp'
|
|
||||||
exportsDir = Path('static') / 'exports'
|
|
||||||
emojiPath = Path.cwd() / 'static' / 'emojis'
|
|
||||||
appName = 'CatAsk'
|
appName = 'CatAsk'
|
||||||
version = '2.0.0'
|
version = '1.5.6'
|
||||||
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
|
# id (identifier) is to be interpreted as described in https://semver.org/#spec-item-9
|
||||||
version_id = '-stable'
|
version_id = '-alpha'
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,45 +0,0 @@
|
||||||
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
54
docker.md
|
@ -1,54 +0,0 @@
|
||||||
# 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
|
|
||||||
```
|
|
699
functions.py
699
functions.py
|
@ -1,48 +1,18 @@
|
||||||
from flask import url_for, request, jsonify, Flask, abort, session
|
from flask import url_for, request
|
||||||
from flask_babel import Babel, _, refresh
|
|
||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from bleach.sanitizer import Cleaner
|
from bleach.sanitizer import Cleaner
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from mistune import HTMLRenderer, escape
|
from mistune import HTMLRenderer, escape
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from psycopg.rows import dict_row
|
|
||||||
import base64
|
|
||||||
import time
|
|
||||||
import zipfile
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import mistune
|
import mistune
|
||||||
import humanize
|
import humanize
|
||||||
import psycopg
|
import mysql.connector
|
||||||
import re
|
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import json
|
import json
|
||||||
import requests
|
|
||||||
import constants as const
|
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
|
# load json file
|
||||||
def loadJSON(file_path):
|
def loadJSON(file_path):
|
||||||
# open the file
|
# open the file
|
||||||
|
@ -59,61 +29,17 @@ def saveJSON(dict, file_path):
|
||||||
# dump the contents
|
# dump the contents
|
||||||
json.dump(dict, file, indent=4)
|
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)
|
cfg = loadJSON(const.configFile)
|
||||||
|
|
||||||
def formatRelativeTime(date_str: str) -> str:
|
def formatRelativeTime(date_str):
|
||||||
date_format = "%Y-%m-%d %H:%M:%S"
|
date_format = "%Y-%m-%d %H:%M:%S"
|
||||||
past_date = datetime.strptime(date_str, date_format).replace(tzinfo=None)
|
past_date = datetime.strptime(date_str, date_format)
|
||||||
|
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
time_difference = now - past_date
|
time_difference = now - past_date
|
||||||
|
|
||||||
return humanize.naturaltime(time_difference)
|
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")
|
dbHost = os.environ.get("DB_HOST")
|
||||||
dbUser = os.environ.get("DB_USER")
|
dbUser = os.environ.get("DB_USER")
|
||||||
dbPass = os.environ.get("DB_PASS")
|
dbPass = os.environ.get("DB_PASS")
|
||||||
|
@ -122,239 +48,58 @@ dbPort = os.environ.get("DB_PORT")
|
||||||
if not dbPort:
|
if not dbPort:
|
||||||
dbPort = 3306
|
dbPort = 3306
|
||||||
|
|
||||||
def createDatabase(cursor, dbName) -> None:
|
def createDatabase(cursor, dbName):
|
||||||
try:
|
try:
|
||||||
cursor.execute("CREATE DATABASE {} OWNER {}".format(dbName, dbUser))
|
cursor.execute("CREATE DATABASE {} DEFAULT CHARACTER SET 'utf8'".format(dbName))
|
||||||
print(f"Database {dbName} created successfully")
|
print(f"Database {dbName} created successfully")
|
||||||
except psycopg.Error as error:
|
except mysql.connector.Error as error:
|
||||||
print("Failed to create database:", error)
|
print("Failed to create database:", error)
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
def connectToDb():
|
def connectToDb():
|
||||||
# using dict_row factory here because its easier than modifying now-legacy mysql code
|
conn = mysql.connector.connect(
|
||||||
return psycopg.connect(f"postgresql://{dbUser}:{dbPass}@{dbHost}/{dbName}", row_factory=dict_row)
|
host=dbHost,
|
||||||
|
user=dbUser,
|
||||||
|
password=dbPass,
|
||||||
|
database=dbName,
|
||||||
|
port=dbPort,
|
||||||
|
autocommit=True
|
||||||
|
)
|
||||||
|
return conn
|
||||||
|
|
||||||
def getQuestion(question_id: int) -> dict:
|
def getQuestion(question_id: int):
|
||||||
conn = connectToDb()
|
conn = connectToDb()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor(dictionary=True)
|
||||||
cursor.execute("SELECT * FROM questions WHERE id=%s", (question_id,))
|
cursor.execute("SELECT * FROM questions WHERE id=%s", (question_id,))
|
||||||
question = cursor.fetchone()
|
question = cursor.fetchone()
|
||||||
question['creation_date'] = question['creation_date'].replace(microsecond=0).replace(tzinfo=None)
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return question
|
return question
|
||||||
|
|
||||||
def getAllQuestions(limit: int = None, offset: int = None) -> dict:
|
def getAnswer(question_id: int):
|
||||||
conn = connectToDb()
|
conn = connectToDb()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor(dictionary=True)
|
||||||
|
|
||||||
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,))
|
cursor.execute("SELECT * FROM answers WHERE question_id=%s", (question_id,))
|
||||||
answer = cursor.fetchone()
|
answer = cursor.fetchone()
|
||||||
answer['creation_date'] = answer['creation_date'].replace(microsecond=0).replace(tzinfo=None)
|
|
||||||
cursor.close()
|
cursor.close()
|
||||||
conn.close()
|
conn.close()
|
||||||
return answer
|
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):
|
def readPlainFile(file, split=False):
|
||||||
if os.path.exists(file):
|
if os.path.exists(file):
|
||||||
with open(file, 'r', encoding="utf-8") as file:
|
with open(file, 'r', encoding="utf-8") as file:
|
||||||
if split:
|
if split == False:
|
||||||
return file.read().splitlines()
|
|
||||||
else:
|
|
||||||
return file.read()
|
return file.read()
|
||||||
|
if split == True:
|
||||||
|
return file.read().splitlines()
|
||||||
else:
|
else:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def savePlainFile(file, contents) -> None:
|
def getRandomWord():
|
||||||
with open(file, 'w') as file:
|
|
||||||
file.write(contents)
|
|
||||||
|
|
||||||
def getRandomWord() -> str:
|
|
||||||
items = readPlainFile(const.antiSpamFile, split=True)
|
items = readPlainFile(const.antiSpamFile, split=True)
|
||||||
return random.choice(items)
|
return random.choice(items)
|
||||||
|
|
||||||
def trimContent(var, trim) -> str:
|
def trimContent(var, trim):
|
||||||
trim = int(trim)
|
trim = int(trim)
|
||||||
if trim > 0:
|
if trim > 0:
|
||||||
trimmed = var[:trim] + '…' if len(var) >= trim else var
|
trimmed = var[:trim] + '…' if len(var) >= trim else var
|
||||||
|
@ -372,208 +117,37 @@ def parse_inline_button(inline, m, state):
|
||||||
return m.end()
|
return m.end()
|
||||||
|
|
||||||
def render_inline_button(renderer, text):
|
def render_inline_button(renderer, text):
|
||||||
return f"<button class='btn btn-secondary' type='button'>{text}</button>"
|
return f"<button class='btn btn-outline-secondary'>{text}</button>"
|
||||||
|
|
||||||
|
|
||||||
def button(md):
|
def button(md):
|
||||||
md.inline.register('inline_button', inlineBtnPattern, parse_inline_button, before='link')
|
md.inline.register('inline_button', inlineBtnPattern, parse_inline_button, before='link')
|
||||||
if md.renderer and md.renderer.NAME == 'html':
|
if md.renderer and md.renderer.NAME == 'html':
|
||||||
md.renderer.register('inline_button', render_inline_button)
|
md.renderer.register('inline_button', render_inline_button)
|
||||||
|
|
||||||
# Base directory where emoji packs are stored
|
def renderMarkdown(text):
|
||||||
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 = [
|
plugins = [
|
||||||
'strikethrough',
|
'strikethrough',
|
||||||
button,
|
button
|
||||||
emoji
|
]
|
||||||
|
allowed_tags = [
|
||||||
|
'p',
|
||||||
|
'em',
|
||||||
|
'b',
|
||||||
|
'strong',
|
||||||
|
'i',
|
||||||
|
'br',
|
||||||
|
's',
|
||||||
|
'del',
|
||||||
|
'a',
|
||||||
|
'button',
|
||||||
|
'ol',
|
||||||
|
'li',
|
||||||
|
'hr'
|
||||||
]
|
]
|
||||||
if not allowed_tags:
|
|
||||||
allowed_tags = [
|
|
||||||
'p',
|
|
||||||
'em',
|
|
||||||
'b',
|
|
||||||
'strong',
|
|
||||||
'i',
|
|
||||||
'br',
|
|
||||||
's',
|
|
||||||
'del',
|
|
||||||
'a',
|
|
||||||
'button',
|
|
||||||
'ol',
|
|
||||||
'li',
|
|
||||||
'hr',
|
|
||||||
'img',
|
|
||||||
'code',
|
|
||||||
'pre'
|
|
||||||
]
|
|
||||||
allowed_attrs = {
|
allowed_attrs = {
|
||||||
'a': 'href',
|
'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
|
# hard_wrap=True means that newlines will be
|
||||||
# converted into <br> tags
|
# converted into <br> tags
|
||||||
|
@ -592,7 +166,7 @@ def renderMarkdown(text: str, allowed_tags: bool = None) -> str:
|
||||||
clean_html = cleaner.clean(html)
|
clean_html = cleaner.clean(html)
|
||||||
return Markup(clean_html)
|
return Markup(clean_html)
|
||||||
|
|
||||||
def generateMetadata(question: str = None, answer: str = None) -> dict:
|
def generateMetadata(question=None, answer=None):
|
||||||
metadata = {
|
metadata = {
|
||||||
'title': cfg['instance']['title'],
|
'title': cfg['instance']['title'],
|
||||||
'description': cfg['instance']['description'],
|
'description': cfg['instance']['description'],
|
||||||
|
@ -612,23 +186,12 @@ def generateMetadata(question: str = None, answer: str = None) -> dict:
|
||||||
# return 'metadata' dictionary
|
# return 'metadata' dictionary
|
||||||
return metadata
|
return metadata
|
||||||
|
|
||||||
allowedFileExtensions = {'png', 'jpg', 'jpeg', 'webp', 'bmp', 'jxl', 'gif'}
|
allowedFileExtensions = {'png', 'jpg', 'jpeg', 'webp', 'bmp', 'jxl'}
|
||||||
allowedArchiveExtensions = {'zip', 'tar', 'gz', 'bz2', 'xz'}
|
|
||||||
|
|
||||||
def allowedFile(filename: str) -> bool:
|
def allowedFile(filename):
|
||||||
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowedFileExtensions
|
return '.' in filename and filename.rsplit('.', 1)[1].lower() in allowedFileExtensions
|
||||||
|
|
||||||
def allowedArchive(filename: str) -> bool:
|
def generateFavicon(file_name):
|
||||||
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 = {
|
sizes = {
|
||||||
'apple-touch-icon.png': (180, 180),
|
'apple-touch-icon.png': (180, 180),
|
||||||
'android-chrome-192x192.png': (192, 192),
|
'android-chrome-192x192.png': (192, 192),
|
||||||
|
@ -647,165 +210,3 @@ def generateFavicon(file_name: str) -> None:
|
||||||
resized_img = img.resize(size)
|
resized_img = img.resize(size)
|
||||||
resized_img_absolute_path = const.faviconDir / filename
|
resized_img_absolute_path = const.faviconDir / filename
|
||||||
resized_img.save(resized_img_absolute_path)
|
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
|
|
||||||
"""
|
|
||||||
|
|
41
install.sh
41
install.sh
|
@ -1,9 +1,9 @@
|
||||||
#!/usr/bin/bash
|
#!/usr/bin/bash
|
||||||
|
|
||||||
working_dir=$PWD/catask
|
working_dir=~/.local/share/catask
|
||||||
bold=$(tput bold)
|
bold=$(tput bold)
|
||||||
normal=$(tput sgr0)
|
normal=$(tput sgr0)
|
||||||
git_repo_url="https://git.mst.k.vu/catask-org/catask"
|
git_repo_url="https://git.mst.k.vu/mst/catask"
|
||||||
git_repo_issue_url="${git_repo_url}/issues/new"
|
git_repo_issue_url="${git_repo_url}/issues/new"
|
||||||
|
|
||||||
echo "--------------------------"
|
echo "--------------------------"
|
||||||
|
@ -12,9 +12,9 @@ echo "--------------------------"
|
||||||
echo
|
echo
|
||||||
echo "${bold}Cloning the repository...${normal}"
|
echo "${bold}Cloning the repository...${normal}"
|
||||||
# this might work... or not, who knows
|
# this might work... or not, who knows
|
||||||
# id -u catask >/dev/null 2>&1 || sudo useradd -r -s /bin/false -m -d $working_dir/home/catask -U catask
|
id -u catask >/dev/null 2>&1 || sudo useradd -r -s /bin/false -m -d /etc/catask -U catask
|
||||||
# cloning dev branch for now because the installer doesn't exist in main branch yet
|
# cloning dev branch for now because the installer doesn't exist in main branch yet
|
||||||
git clone $git_repo_url $working_dir
|
sudo git clone $git_repo_url $working_dir --branch dev
|
||||||
cd $working_dir
|
cd $working_dir
|
||||||
echo
|
echo
|
||||||
echo "${bold}Creating & activating virtual environment...${normal}"
|
echo "${bold}Creating & activating virtual environment...${normal}"
|
||||||
|
@ -64,16 +64,10 @@ echo
|
||||||
echo "${bold}Configuring CatAsk...${normal}"
|
echo "${bold}Configuring CatAsk...${normal}"
|
||||||
cp $working_dir/.env.example $working_dir/.env; cp $working_dir/config.example.json $working_dir/config.json
|
cp $working_dir/.env.example $working_dir/.env; cp $working_dir/config.example.json $working_dir/config.json
|
||||||
echo
|
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..."
|
read -n 1 -s -r -p "Press any key to open main config file..."
|
||||||
$editor_ config.json
|
nano config.json
|
||||||
read -n 1 -s -r -p "Press any key to open .env config file..."
|
read -n 1 -s -r -p "Press any key to open .env config file..."
|
||||||
$editor_ .env
|
nano .env
|
||||||
|
|
||||||
echo
|
echo
|
||||||
echo "${bold}Initializing the database...${normal}"
|
echo "${bold}Initializing the database...${normal}"
|
||||||
|
@ -95,20 +89,17 @@ if [[ "${sysd_service_input,,}" == 'n' ]]; then
|
||||||
echo "Replace ${bold}127.0.0.1:5000${normal} with address where CatAsk will run"
|
echo "Replace ${bold}127.0.0.1:5000${normal} with address where CatAsk will run"
|
||||||
exit 0
|
exit 0
|
||||||
else
|
else
|
||||||
read -p "Address on which CatAsk will run (127.0.0.1:5000): " catask_addr
|
read -p "Address on which CatAsk will run (127.0.0.1:5000): " catask_addr
|
||||||
catask_addr=${catask_addr:-127.0.0.1:5000}
|
catask_addr=${catask_addr:-127.0.0.1:5000}
|
||||||
sudo cat > /etc/systemd/system/catask.service << EOF
|
sudo cat > /etc/systemd/system/catask.service << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=CatAsk
|
Description=catask
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
User=%u
|
User=catask
|
||||||
WorkingDirectory=$working_dir
|
WorkingDirectory=$PWD
|
||||||
ExecStart=$working_dir/venv/bin/python3 -m gunicorn -w 4 app:app -b $catask_addr
|
ExecStart=$PWD/venv/bin/python3 -m gunicorn -w 4 app:app -b $catask_addr
|
||||||
|
EOF
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target
|
|
||||||
EOF
|
|
||||||
fi
|
fi
|
||||||
echo "Created a systemd service with these contents:"
|
echo "Created a systemd service with these contents:"
|
||||||
echo "-----------------------"
|
echo "-----------------------"
|
||||||
|
|
Binary file not shown.
File diff suppressed because it is too large
Load diff
1213
messages.pot
1213
messages.pot
File diff suppressed because it is too large
Load diff
|
@ -1,12 +1,11 @@
|
||||||
flask
|
flask
|
||||||
python-dotenv
|
python-dotenv
|
||||||
psycopg[binary,pool]
|
mysql-connector-python==8.2.0
|
||||||
humanize
|
humanize
|
||||||
mistune
|
mistune
|
||||||
bleach
|
bleach
|
||||||
pathlib
|
pathlib
|
||||||
Flask-Compress
|
Flask-Compress
|
||||||
gunicorn
|
gunicorn
|
||||||
|
Flask-Limiter
|
||||||
pillow
|
pillow
|
||||||
requests
|
|
||||||
Flask-Babel
|
|
||||||
|
|
28
roadmap.md
28
roadmap.md
|
@ -1,29 +1,9 @@
|
||||||
# CatAsk to-do
|
# CatAsk stable roadmap
|
||||||
|
|
||||||
* [ ] fediverse verification
|
* [ ] content warnings
|
||||||
* [ ] setting: set custom background image
|
* [ ] multiple anti-spam options
|
||||||
* [ ] 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
|
* [ ] collapse long questions & answers
|
||||||
* [x] add custom emojis
|
* [ ] add custom emojis
|
||||||
* [x] move to toastify for alerts
|
* [x] move to toastify for alerts
|
||||||
* [x] make stuff more accessible
|
* [x] make stuff more accessible
|
||||||
* [ ] implement private questions
|
* [ ] implement private questions
|
||||||
|
|
33
schema.sql
33
schema.sql
|
@ -1,24 +1,21 @@
|
||||||
CREATE TABLE IF NOT EXISTS answers (
|
CREATE TABLE IF NOT EXISTS answers (
|
||||||
id SERIAL PRIMARY KEY,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
question_id INTEGER NOT NULL,
|
question_id INT NOT NULL,
|
||||||
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL
|
||||||
cw VARCHAR(255) NOT NULL DEFAULT ''
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS questions (
|
CREATE TABLE IF NOT EXISTS questions (
|
||||||
id SERIAL PRIMARY KEY,
|
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||||
from_who VARCHAR(255) NOT NULL,
|
from_who VARCHAR(255) NOT NULL,
|
||||||
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
creation_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
content TEXT NOT NULL,
|
content TEXT NOT NULL,
|
||||||
answered BOOLEAN NOT NULL DEFAULT FALSE,
|
answered BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
answer_id INTEGER,
|
answer_id INT,
|
||||||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
pinned BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
cw VARCHAR(255) NOT NULL DEFAULT '',
|
-- below is reserved for version 1.6.0 or later
|
||||||
unread BOOLEAN NOT NULL DEFAULT TRUE
|
-- private BOOLEAN NOT NULL DEFAULT FALSE
|
||||||
-- private BOOLEAN NOT NULL DEFAULT FALSE, -- For later use
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
-- user_ip BYTEA NOT NULL DEFAULT '' -- For later use
|
|
||||||
);
|
|
||||||
|
|
||||||
ALTER TABLE questions
|
ALTER TABLE questions
|
||||||
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;
|
ADD CONSTRAINT fk_answer_id FOREIGN KEY (answer_id) REFERENCES answers(id) ON DELETE CASCADE;
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
|
@font-face {
|
||||||
|
font-family: "Rubik";
|
||||||
|
font-display: swap;
|
||||||
|
font-weight: 100 900;
|
||||||
|
src: url("../fonts/rubik.woff2") format('woff2-variations');
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--bs-font-sans-serif: "Rubik", sans-serif;
|
||||||
--bs-link-color-rgb: var(--bs-primary-rgb);
|
--bs-link-color-rgb: var(--bs-primary-rgb);
|
||||||
--bs-nav-link-color: var(--bs-primary);
|
--bs-nav-link-color: var(--bs-primary);
|
||||||
--bs-border-radius: .5rem;
|
--bs-border-radius: .5rem;
|
||||||
|
@ -11,14 +19,14 @@
|
||||||
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
||||||
--bs-danger: #dc3545;
|
--bs-danger: #dc3545;
|
||||||
--bs-danger-bg-subtle: #f8e6e8;
|
--bs-danger-bg-subtle: #f8e6e8;
|
||||||
--bs-link-color: color-mix(in srgb, var(--bs-primary) 70%, var(--bs-body-color));
|
--bs-link-color: var(--bs-primary);
|
||||||
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, black);
|
--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: 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-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, black);
|
||||||
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, black);
|
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, black);
|
||||||
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, black);
|
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, black);
|
||||||
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
|
|
||||||
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), black);
|
--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] {
|
[data-bs-theme=dark] {
|
||||||
|
@ -30,22 +38,14 @@
|
||||||
--bs-danger: #e06672;
|
--bs-danger: #e06672;
|
||||||
--bs-danger-rgb: 224, 102, 114;
|
--bs-danger-rgb: 224, 102, 114;
|
||||||
--bs-danger-bg-subtle: #2c0b0e;
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
--bs-link-color: color-mix(in srgb, var(--bs-primary), white);
|
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, white);
|
||||||
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
||||||
--bs-basic-btn-hover-bg:color-mix(in srgb, var(--bs-body-bg) 95%, 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-hover-bg-strong: color-mix(in srgb, var(--bs-body-bg) 90%, white);
|
||||||
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, white);
|
--bs-basic-btn-active-bg: color-mix(in srgb, var(--bs-body-bg) 92%, white);
|
||||||
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, white);
|
--bs-basic-btn-active-bg-strong: color-mix(in srgb, var(--bs-body-bg) 87%, white);
|
||||||
--bs-btn-active-bg: var(--bs-basic-btn-active-bg-strong);
|
|
||||||
--bs-primary-text-emphasis: color-mix(in srgb, var(--bs-primary), white);
|
--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,
|
[data-bs-theme=light] .light-invert,
|
||||||
|
@ -53,21 +53,6 @@
|
||||||
filter: invert();
|
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 {
|
.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-bg: var(--bs-basic-btn-active-bg-strong) !important;
|
||||||
--bs-btn-active-color: var(--bs-body-color);
|
--bs-btn-active-color: var(--bs-body-color);
|
||||||
|
@ -78,33 +63,9 @@
|
||||||
--bs-btn-border-color: var(--bs-basic-btn-active-bg-strong);
|
--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 {
|
.btn-outline-danger {
|
||||||
--bs-btn-color: var(--bs-danger);
|
--bs-btn-color: var(--bs-danger);
|
||||||
--bs-btn-border-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 {
|
.btn {
|
||||||
|
@ -149,29 +110,11 @@
|
||||||
--bs-btn-active-color: var(--bs-body-color);
|
--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 {
|
.btn-basic:hover, .btn-basic:focus {
|
||||||
background-color: var(--bs-basic-btn-hover-bg);
|
background-color: var(--bs-basic-btn-hover-bg);
|
||||||
}
|
}
|
||||||
.btn-basic:active {
|
.btn-basic:active {
|
||||||
background-color: var(--bs-basic-btn-active-bg) !important;
|
background-color: var(--bs-basic-btn-active-bg);
|
||||||
}
|
}
|
||||||
.card-footer .btn-basic:hover, .card-footer .btn-basic:focus {
|
.card-footer .btn-basic:hover, .card-footer .btn-basic:focus {
|
||||||
background-color: var(--bs-basic-btn-hover-bg-strong) !important;
|
background-color: var(--bs-basic-btn-hover-bg-strong) !important;
|
||||||
|
@ -182,16 +125,6 @@
|
||||||
}
|
}
|
||||||
.btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active {
|
.btn-check:checked + .btn, .btn.active, .btn.show, .btn:first-child:active, :not(.btn-check) + .btn:active {
|
||||||
border-color: transparent;
|
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 {
|
[data-bs-theme=light] .nav-link, a {
|
||||||
|
@ -242,7 +175,8 @@ a:hover {
|
||||||
border-radius: var(--bs-border-radius);
|
border-radius: var(--bs-border-radius);
|
||||||
}
|
}
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
padding: .25em .6em;
|
padding-left: .6em;
|
||||||
|
padding-right: .6em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-hover-danger.dropdown-item:hover {
|
.bg-hover-danger.dropdown-item:hover {
|
||||||
|
@ -250,21 +184,17 @@ a:hover {
|
||||||
--bs-dropdown-link-hover-color: var(--bs-danger);
|
--bs-dropdown-link-hover-color: var(--bs-danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active {
|
|
||||||
--bs-dropdown-link-active-bg: var(--bs-danger);
|
|
||||||
background-color: 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 {
|
.bg-hover-danger.dropdown-item.active, .bg-hover-danger.dropdown-item:active {
|
||||||
color: var(--bs-dropdown-link-active-color) !important;
|
--bs-dropdown-link-active-bg: var(--bs-danger);
|
||||||
|
background-color: red !important;
|
||||||
|
color: white !important;
|
||||||
|
--bs-dropdown-link-active-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus, .form-check-input:focus,
|
.form-control:focus, .form-check-input:focus,
|
||||||
.accordion-button:focus, .btn:focus-visible, .form-select:focus {
|
.accordion-button:focus, .btn:focus-visible {
|
||||||
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
box-shadow: 0 0 0 .25rem color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||||
border-color: color-mix(in srgb, var(--bs-primary) 80%, transparent);
|
border-color: color-mix(in srgb, var(--bs-primary), transparent);
|
||||||
outline: 0;
|
outline: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -277,6 +207,26 @@ a:hover {
|
||||||
box-shadow: var(--bs-box-shadow);
|
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 {
|
.form-check-input:checked {
|
||||||
background-color: var(--bs-primary);
|
background-color: var(--bs-primary);
|
||||||
border-color: var(--bs-primary);
|
border-color: var(--bs-primary);
|
||||||
|
@ -332,36 +282,3 @@ a:hover {
|
||||||
.text-bg-primary {
|
.text-bg-primary {
|
||||||
background-color: var(--bs-primary) !important;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,7 +5,7 @@
|
||||||
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, white);
|
||||||
--bs-danger: #dc3545;
|
--bs-danger: #dc3545;
|
||||||
--bs-danger-bg-subtle: #f8e6e8;
|
--bs-danger-bg-subtle: #f8e6e8;
|
||||||
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, var(--bs-body-color));
|
--bs-link-color: color-mix(in srgb, var(--bs-primary) 75%, black);
|
||||||
--bs-link-hover-color: color-mix(in srgb, var(--bs-primary) 80%, black);
|
--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: 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));
|
--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-dropdown-active-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
|
||||||
--bs-tertiary-bg: var(--bs-primary-bg-subtle);
|
--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-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] {
|
[data-bs-theme=dark] {
|
||||||
--bs-body-bg-untinted: #202020;
|
--bs-body-bg-untinted: #202020;
|
||||||
--bs-body-bg: color-mix(in srgb, var(--bs-primary) 5%, var(--bs-body-bg-untinted));
|
--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-primary-bg-subtle: color-mix(in srgb, var(--bs-primary) 20%, transparent);
|
||||||
--bs-danger-bg-subtle: #2c0b0e;
|
--bs-danger-bg-subtle: #2c0b0e;
|
||||||
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, white);
|
--bs-link-color: color-mix(in srgb, var(--bs-primary) 55%, white);
|
||||||
|
@ -35,11 +35,6 @@
|
||||||
--bs-dropdown-active-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
|
--bs-dropdown-active-bg: color-mix(in srgb, var(--bs-primary) 70%, black);
|
||||||
--bs-tertiary-bg: var(--bs-primary-bg-subtle);
|
--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-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 {
|
.btn-check:checked + .btn-secondary, .btn-check:checked + .btn-outline-secondary {
|
||||||
|
@ -95,9 +90,4 @@
|
||||||
[data-bs-theme="light"] .dropdown-item:hover, [data-bs-theme="light"] .dropdown-item:focus {
|
[data-bs-theme="light"] .dropdown-item:hover, [data-bs-theme="light"] .dropdown-item:focus {
|
||||||
--bs-dropdown-link-hover-bg: color-mix(in srgb, var(--bs-primary) 10%, transparent);
|
--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);
|
--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.
|
@ -1,237 +0,0 @@
|
||||||
<?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>
|
|
Before Width: | Height: | Size: 11 KiB |
1
static/js/codemirror-css.min.js
vendored
1
static/js/codemirror-css.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
static/js/toastify.min.js
vendored
2
static/js/toastify.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -1,117 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,49 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,117 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,16 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,498 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,88 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,83 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,86 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,41 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,39 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,54 +0,0 @@
|
||||||
{% 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 & 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 %}
|
|
336
templates/admin/index.html
Normal file
336
templates/admin/index.html
Normal file
|
@ -0,0 +1,336 @@
|
||||||
|
{% 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 %}
|
|
@ -1,22 +1,17 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ _('Admin Login') }}{% endblock %}
|
{% block title %}Admin Login{% endblock %}
|
||||||
{% set loginLink = 'active' %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-lg-3 m-auto mt-5">
|
<div class="col-lg-4 m-auto mt-5">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<h2 class="text-center mb-4 mt-2">{{ _('Login to {}').format(cfg.instance.title) }}</h2>
|
<h2 class="text-center mb-4 mt-2">Login to {{ cfg.instance.title }}</h2>
|
||||||
<form action="{{ url_for('admin.login') }}" method="POST">
|
<form action="{{ url_for('admin.login') }}" method="POST">
|
||||||
<div class="form-floating mb-2">
|
<div class="form-floating mb-3">
|
||||||
<input type="password" required class="form-control" id="admin_password" name="admin_password" placeholder="{{ _('Password') }}">
|
<input type="password" required class="form-control" id="admin_password" name="admin_password" placeholder="Password">
|
||||||
<label for="admin_password">{{ _('Password') }}</label>
|
<label for="admin_password">Password</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check mb-3">
|
<button type="submit" class="btn btn-primary w-100">Login</button>
|
||||||
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,182 +1,13 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<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 charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<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') }}">
|
<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') }}">
|
<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="stylesheet" href="{{ url_for('static', filename='css/bootstrap-icons.min.css') }}">
|
||||||
{%- if cfg.accessibility.font == 'default' -%}
|
<link rel="preload" href="{{ url_for('static', filename='fonts/bootstrap-icons.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
<link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
|
<link rel="preload" href="{{ url_for('static', filename='fonts/rubik.woff2') }}" as="font" type="font/woff2" crossorigin>
|
||||||
{%- endif -%}
|
|
||||||
|
|
||||||
<!-- favicon -->
|
<!-- favicon -->
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}">
|
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='icons/favicon/apple-touch-icon.png') }}">
|
||||||
|
@ -205,60 +36,50 @@
|
||||||
<meta property="twitter:description" content="{{ metadata.description }}" />
|
<meta property="twitter:description" content="{{ metadata.description }}" />
|
||||||
<meta property="twitter:image" content="{{ metadata.image }}" />
|
<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>
|
<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 %}
|
{% 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>
|
<script src="{{ url_for('static', filename='js/htmx.min.js') }}"></script>
|
||||||
<title>{% block title %}{% endblock %} | {{ cfg.instance.title }}</title>
|
<title>{% block title %}{% endblock %} | {{ cfg.instance.title }}</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="">
|
<body class="ms-2 me-2 mb-2">
|
||||||
<a class="visually-hidden-focusable btn" href="#main-content">Skip to content</a>
|
<a class="visually-hidden-focusable btn" href="#main-content">Skip to content</a>
|
||||||
<div class="mb-2{% if not bodyNoXMargin %} px-3{% endif %} col-xxl-11{% if not noContainerFluid %} container-fluid{% endif %}">
|
<div class="container-fluid">
|
||||||
{% block navbar %}
|
<div class="d-flex {% if logged_in %}justify-content-between {% endif %}align-items-center mt-3 {% if logged_in %}mb-3{% endif %}">
|
||||||
<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">
|
<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') }}" loading="lazy" width="32" height="32" alt="{{ cfg.instance.title }}'s icon"></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') }}" width="32" height="32" alt="{{ cfg.instance.title }}'s icon"></a></li>
|
||||||
<ul class="d-flex p-0">
|
<li class="nav-item d-flex align-items-center"><a class="nav-link {{ homeLink }}" id="home-link" href="{{ url_for('index') }}">Home</a></li>
|
||||||
{% include 'snippets/navLinks.html' %}
|
|
||||||
</ul>
|
|
||||||
</ul>
|
|
||||||
<ul class="nav nav-{{ cfg.style.navStyle }} m-0">
|
|
||||||
{% if logged_in %}
|
{% if logged_in %}
|
||||||
<form action="{{ url_for('admin.logout') }}" method="POST" class="d-none" id="logout_form"></form>
|
<li class="nav-item d-flex align-items-center position-relative">
|
||||||
<li>
|
<a class="nav-link {{ inboxLink }}" id="inbox-link" href="{{ url_for('inbox') }}">
|
||||||
<button form="logout_form" type="submit" class="nav-link"{% if cfg.style.navIconsOnly %} title="{{ _('Logout') }}"{% endif %}>
|
Inbox {# <span class="position-absolute start-100 translate-middle badge text-bg-primary rounded-pill">{{ questionCount }} <span class="visually-hidden">unanswered questions</span></span> #}
|
||||||
{% 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>
|
</a>
|
||||||
</li>
|
</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 %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
{% if logged_in %}
|
||||||
{# will do later
|
<ul class="nav nav-{{ cfg.style.navStyle }} m-0">
|
||||||
<div class="d-flex border-top bg-body z-3 px-3 py-2 d-md-none fixed-bottom mobile-nav">
|
<li><a class="nav-link" href="{{ url_for('admin.logout') }}">Logout</a></li>
|
||||||
<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>
|
</ul>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
#}
|
|
||||||
{% endblock %}
|
|
||||||
{% with messages = get_flashed_messages(with_categories=True) %}
|
{% with messages = get_flashed_messages(with_categories=True) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
{% for category, message in messages %}
|
{% for category, message in messages %}
|
||||||
<div class="alert alert-{{ category }} alert-dismissible col-lg-4 m-auto" role="alert">
|
<div class="alert alert-{{ category }} alert-dismissible" role="alert">
|
||||||
<p class="m-0">{{ message }}</p>
|
<div>{{ message }}</div>
|
||||||
<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>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -266,9 +87,8 @@
|
||||||
<div id="main-content">
|
<div id="main-content">
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
{% block footer %}
|
|
||||||
<footer class="py-3 my-4 d-flex justify-content-between align-items-center">
|
<footer class="py-3 my-4 d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex gap-2">
|
<div class="row">
|
||||||
<div class="dropdown bd-mode-toggle">
|
<div class="dropdown bd-mode-toggle">
|
||||||
<button class="btn btn-outline-secondary py-2 dropdown-toggle"
|
<button class="btn btn-outline-secondary py-2 dropdown-toggle"
|
||||||
id="bd-theme"
|
id="bd-theme"
|
||||||
|
@ -278,95 +98,38 @@
|
||||||
data-bs-auto-close="outside"
|
data-bs-auto-close="outside"
|
||||||
aria-label="Toggle theme (auto)">
|
aria-label="Toggle theme (auto)">
|
||||||
<i class="bi bi-circle-half my-1" id="theme-icon-active"></i>
|
<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>
|
</button>
|
||||||
<ul class="dropdown-menu" aria-labelledby="bd-theme-text">
|
<ul class="dropdown-menu" aria-labelledby="bd-theme-text">
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="light" aria-pressed="false">
|
<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>
|
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="dark" aria-pressed="false">
|
<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>
|
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="dropdown-item d-flex align-items-center" data-bs-theme-value="auto" aria-pressed="false">
|
<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>
|
<i class="bi ms-auto d-none bi-check2 theme-check"></i>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
||||||
<div class="text-body-secondary text-end small">
|
<div class="text-body-secondary text-end small">
|
||||||
<p class="text-decoration-none m-0 d-flex align-items-center gap-1">
|
<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">
|
<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>
|
{{ const.appName }} <span class="fw-medium">{{ version }}{{ version_id }}</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="d-flex gap-2 fs-5 justify-content-end">
|
<a href="https://git.gay/mst/catask" class="icon-link text-decoration-none" target="_blank"><i class="bi bi-git"></i> Source code</a>
|
||||||
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{% endblock %}
|
|
||||||
</div>
|
</div>
|
||||||
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/bootstrap.min.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
|
|
|
@ -1,4 +0,0 @@
|
||||||
{% extends 'errors/base.html' %}
|
|
||||||
{% set error_code = '404' %}
|
|
||||||
{% set error_title = 'Not Found' %}
|
|
||||||
{% set error_description = 'The requested resource could not be found.' %}
|
|
|
@ -1,4 +0,0 @@
|
||||||
{% 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) %}
|
|
|
@ -1,4 +0,0 @@
|
||||||
{% 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) %}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,84 +1,54 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ _('Inbox') }} {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %}
|
{% block title %}Inbox {% if len(questions) > 0 %}({{ len(questions) }}){% endif %}{% endblock %}
|
||||||
{% set inboxLink = 'active' %}
|
{% set inboxLink = 'active' %}
|
||||||
{% block additionalHeadItems %}
|
{% block additionalHeadItems %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% if questions != [] %}
|
{% 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">
|
<div class="row">
|
||||||
{% for question in questions %}
|
{% for question in questions %}
|
||||||
<div class="col-lg-8 m-auto">
|
<div class="col-sm-8 m-auto">
|
||||||
<div class="card mb-3 mt-3 alert-placeholder question" id="question-{{ question.id }}">
|
<div class="card mb-3 mt-3 alert-placeholder question" id="question-{{ question.id }}">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<h5 class="card-title mt-1 mb-0 markdown-content w-50">
|
<h5 class="card-title mt-1 mb-1">
|
||||||
{% if question.from_who %}
|
{% if question.from_who %}
|
||||||
{{ render_markdown(question.from_who, fromWho=True) }}
|
{{ question.from_who }}
|
||||||
{% else %}
|
{% 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 %}
|
{% endif %}
|
||||||
</h5>
|
</h5>
|
||||||
<h6 class="card-subtitle mt-1 fw-light text-body-secondary">
|
<h6 class="card-subtitle fw-light text-body-secondary">
|
||||||
{#
|
{#
|
||||||
reserved for version 1.6.0 or later
|
reserved for version 1.6.0 or later
|
||||||
|
|
||||||
{% if question.private %}
|
{% if question.private %}
|
||||||
<span class="me-2"><i class="bi bi-lock"></i> <span class="fw-medium" data-bs-toggle="tooltip" data-bs-title="This question was asked privately">Private</span></span>
|
<span class="me-2"><i class="bi bi-lock"></i> <span class="fw-medium" data-bs-toggle="tooltip" data-bs-title="This question was asked privately">Private</span></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
#}
|
#}
|
||||||
<span data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime('%B %d, %Y %H:%M') }}">{{ formatRelativeTime(str(question.creation_date)) }}</span>
|
<span data-bs-toggle="tooltip" data-bs-title="{{ question.creation_date.strftime('%B %d, %Y %H:%M') }}">{{ formatRelativeTime(str(question.creation_date)) }}</span>
|
||||||
</h6>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-text markdown-content">
|
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
|
||||||
{% 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>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<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">
|
<form hx-trigger="click from:#answer-btn-{{ question.id }}, keyup[ctrlKey&&key=='Enter']" hx-post="{{ url_for('api.addAnswer', question_id=question.id) }}" hx-target="#question-{{ question.id }}" hx-swap="none">
|
||||||
<div class="form-group d-sm-grid d-md-block gap-2">
|
<div class="form-group d-sm-grid d-md-block gap-2">
|
||||||
<div class="collapse" id="cw-{{ question.id }}-collapse">
|
<label for="answer-{{ question.id }}" class="visually-hidden-focusable">Write your answer...</label>
|
||||||
<div class="form-floating mb-2">
|
<textarea class="form-control mb-2" required name="answer" id="answer-{{ question.id }}" placeholder="Write your answer..."></textarea>
|
||||||
<input class="form-control" type="text" name="cw" id="cw" placeholder="Content warning">
|
<div class="d-flex flex-column flex-md-row-reverse gap-2">
|
||||||
<label for="cw">{{ _('Content warning') }}</label>
|
<button type="submit" class="btn btn-primary" id="answer-btn-{{ question.id }}">
|
||||||
</div>
|
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
|
||||||
</div>
|
<span class="visually-hidden" role="status">Loading...</span>
|
||||||
<div class="form-floating">
|
Answer
|
||||||
<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>
|
</button>
|
||||||
<div class="d-flex flex-column flex-sm-row-reverse gap-2">
|
{% if not cfg.noDeleteConfirm %}
|
||||||
<button type="submit" class="btn btn-primary" id="answer-btn-{{ question.id }}">
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#question-{{ question.id }}-modal">Delete</button>
|
||||||
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
|
{% else %}
|
||||||
<span class="visually-hidden" role="status">{{ _('Loading...') }}</span>
|
<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>
|
||||||
{{ _('Answer') }}
|
{% endif %}
|
||||||
</button>
|
|
||||||
{% if not cfg.noDeleteConfirm %}
|
|
||||||
<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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -88,16 +58,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 fade" id="question-{{ question.id }}-modal" tabindex="-1" aria-labelledby="q-{{ question.id }}-modal-label" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header border-0">
|
<div class="modal-header">
|
||||||
<h1 class="modal-title fs-5 fw-normal" id="q-{{ question.id }}-modal-label">{{ _('Confirmation') }}</h1>
|
<h1 class="modal-title fs-5" 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>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body pt-0 pb-0">
|
<div class="modal-body">
|
||||||
<p>{{ _('Are you sure you want to delete this question?') }}</p>
|
<p class="mb-0">Are you sure you want to delete this question?</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer pt-1 border-0 flex-column flex-sm-row align-items-stretch align-items-sm-center">
|
<div class="modal-footer flex-column flex-lg-row align-items-stretch w-100">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
|
<button type="button" class="btn btn-outline-secondary flex-fill" 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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -107,7 +77,7 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<h2 class="text-center mt-5">{{ _('Inbox is currently empty.') }}</h2>
|
<h2 class="text-center mt-5">Inbox is currently empty.</h2>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
@ -116,34 +86,6 @@
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||||
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||||
|
|
||||||
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) {
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
const jsonResponse = event.detail.xhr.response;
|
const jsonResponse = event.detail.xhr.response;
|
||||||
if (jsonResponse) {
|
if (jsonResponse) {
|
||||||
|
@ -165,9 +107,9 @@
|
||||||
const questions = document.querySelectorAll('.question');
|
const questions = document.querySelectorAll('.question');
|
||||||
const count = questions.length;
|
const count = questions.length;
|
||||||
document.getElementById('question-count-inbox').textContent = count;
|
document.getElementById('question-count-inbox').textContent = count;
|
||||||
document.title = `Inbox (${count}) | {{ const.appName }}` ? count > 0 : "Inbox | {{ const.appName }}";
|
document.title = `Inbox (${count}) | {{ const.appName }}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -1,32 +1,248 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ _('Home') }}{% endblock %}
|
{% block title %}Home{% endblock %}
|
||||||
{% set homeLink = 'active' %}
|
{% set homeLink = 'active' %}
|
||||||
{% block additionalHeadItems %}
|
{% block additionalHeadItems %}
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
|
<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 %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div id="top-response-container"></div>
|
<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 %}">
|
||||||
{% if cfg.style.homepageLayout == 'catask' %}
|
<div>
|
||||||
{% include 'snippets/layout/homepage/normal.html' %}
|
<h1 class="text-center fw-bold">{{ cfg.instance.title }}</h1>
|
||||||
{% elif cfg.style.homepageLayout == 'retrospring' %}
|
{% autoescape off %}
|
||||||
{% include 'snippets/layout/homepage/retrospring.html' %}
|
<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>
|
||||||
{% endif %}
|
{% 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 %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/emoji-mart.js') }}"></script>
|
|
||||||
<script>
|
<script>
|
||||||
{% if not cfg.lockInbox %}
|
// 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();
|
||||||
|
}
|
||||||
|
|
||||||
const input = document.getElementById('question');
|
const input = document.getElementById('question');
|
||||||
const charCount = document.getElementById('charCount');
|
const charCount = document.getElementById('charCount');
|
||||||
function updateCharCount() {
|
function updateCharCount() {
|
||||||
|
@ -47,232 +263,30 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
input.addEventListener('input', updateCharCount);
|
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) {
|
function shareOnFediverse(questionId, contentToShare) {
|
||||||
const instanceDomain = document.getElementById(`fediInstance`).value.trim();
|
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
|
||||||
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
|
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
|
||||||
|
|
||||||
window.open(shareUrl, '_blank');
|
window.open(shareUrl, '_blank');
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTooltips() {
|
</script>
|
||||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
<script>
|
||||||
const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
document.getElementById('question-form').reset();
|
||||||
}
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.addEventListener('htmx:afterRequest', function(event) {
|
document.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.target.id != "question-count") {
|
||||||
|
document.getElementById('ask-btn').removeAttribute('disabled');
|
||||||
|
}
|
||||||
const jsonResponse = event.detail.xhr.response;
|
const jsonResponse = event.detail.xhr.response;
|
||||||
if (jsonResponse) {
|
if (jsonResponse) {
|
||||||
const parsed = JSON.parse(jsonResponse);
|
const parsed = JSON.parse(jsonResponse);
|
||||||
|
@ -281,12 +295,8 @@
|
||||||
let targetElementId = event.detail.target.id;
|
let targetElementId = event.detail.target.id;
|
||||||
if (targetElementId != "question-count") {
|
if (targetElementId != "question-count") {
|
||||||
// WARNING: HACK
|
// WARNING: HACK
|
||||||
// we use this hack to avoid triggering the event listener twice when making a request to api.returnToInbox and api.(un)pinQuestion
|
// we use this hack to avoid triggering the event listener twice when making a request to api.returnToInbox
|
||||||
if (
|
if (document.getElementById(targetElementId) && event.detail.requestConfig.elt.dataset.returntoinbox === "") {
|
||||||
(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;
|
targetElementId = event.detail.requestConfig.elt.dataset.target;
|
||||||
document.getElementById(targetElementId).outerHTML = '';
|
document.getElementById(targetElementId).outerHTML = '';
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,19 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,8 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,26 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,30 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,90 +0,0 @@
|
||||||
{% 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>
|
|
|
@ -1,81 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,16 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,229 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,40 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,33 +0,0 @@
|
||||||
{% 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 %}
|
|
|
@ -1,14 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,20 +0,0 @@
|
||||||
<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>
|
|
|
@ -1,121 +1,112 @@
|
||||||
{% extends 'base.html' %}
|
{% extends 'base.html' %}
|
||||||
{% block title %}{{ trimContent(question.content, cfg.trimContentAfter) }} - {{ trimContent(answer.content, cfg.trimContentAfter) }}{% endblock %}
|
{% block title %}{{ trimContent(question.content, 30) }} - {{ trimContent(answer.content, 30) }}{% endblock %}
|
||||||
{% block additionalHeadItems %}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/toastify.css') }}">
|
|
||||||
{% endblock %}
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-8 m-auto">
|
<div class="col-sm-8 m-auto">
|
||||||
{% with answers=answer, noManageBtns=True %}
|
<div class="card mt-2 mb-2" id="question-{{ question.id }}">
|
||||||
{% include 'snippets/layout/question_card.html' %}
|
<div class="card-header">
|
||||||
{% endwith %}
|
<div class="d-flex justify-content-between align-items-center flex-wrap text-break">
|
||||||
</div>
|
<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>
|
||||||
</div>
|
<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 class="modal fade" id="question-modal" tabindex="-1" aria-labelledby="q-modal-label" aria-hidden="true">
|
</div>
|
||||||
<div class="modal-dialog modal-dialog-centered">
|
<div class="card-text markdown-content">{{ question.content | render_markdown }}</div>
|
||||||
<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>
|
||||||
<div class="modal-body py-0">
|
<div class="card-body">
|
||||||
<div class="form-group mb-3">
|
<div class="markdown-content">{{ answer.content | render_markdown }}</div>
|
||||||
<label for="fediInstance" class="form-label">{{ _('Fediverse instance domain:') }}</label>
|
</div>
|
||||||
<input type="text" id="fediInstance" name="fediInstance" class="form-control">
|
<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>
|
</div>
|
||||||
<div class="modal-footer pt-1 border-0">
|
</div>
|
||||||
<button type="button" class="btn btn-outline-secondary flex-fill flex-lg-grow-0" data-bs-dismiss="modal">{{ _('Cancel') }}</button>
|
</div>
|
||||||
<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 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">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-{{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('{{ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/toastify.min.js') }}"></script>
|
|
||||||
<script>
|
<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) {
|
function copy(questionId) {
|
||||||
navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/");
|
navigator.clipboard.writeText("{{ cfg.instance.fullBaseUrl }}/q/" + questionId + "/")
|
||||||
Toastify({
|
|
||||||
text: "{{ _('Successfully copied link to clipboard!') }}",
|
|
||||||
duration: 3000,
|
|
||||||
gravity: "top",
|
|
||||||
position: "right",
|
|
||||||
stopOnFocus: true,
|
|
||||||
className: `alert alert-success shadow alert-dismissible`,
|
|
||||||
close: true
|
|
||||||
}).showToast();
|
|
||||||
}
|
}
|
||||||
function copyFull(text) {
|
function copyFull(text) {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
Toastify({
|
|
||||||
text: "{{ _('Successfully copied text to clipboard!') }}",
|
|
||||||
duration: 3000,
|
|
||||||
gravity: "top",
|
|
||||||
position: "right",
|
|
||||||
stopOnFocus: true,
|
|
||||||
className: `alert alert-success shadow alert-dismissible`,
|
|
||||||
close: true
|
|
||||||
}).showToast();
|
|
||||||
}
|
}
|
||||||
function shareOnFediverse(questionId, contentToShare) {
|
function shareOnFediverse(questionId, contentToShare) {
|
||||||
const instanceDomain = document.getElementById(`fediInstance`).value.trim();
|
const instanceDomain = document.getElementById(`fediInstance-${questionId}`).value.trim();
|
||||||
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
|
const shareUrl = `https://${instanceDomain}/share?text=${contentToShare}`;
|
||||||
|
|
||||||
window.open(shareUrl, '_blank');
|
window.open(shareUrl, '_blank');
|
||||||
|
@ -135,16 +126,9 @@ function shareOnFediverse(questionId, contentToShare) {
|
||||||
const parsed = JSON.parse(jsonResponse);
|
const parsed = JSON.parse(jsonResponse);
|
||||||
const alertType = event.detail.successful ? 'success' : 'danger';
|
const alertType = event.detail.successful ? 'success' : 'danger';
|
||||||
msgType = event.detail.successful ? parsed.message : parsed.error;
|
msgType = event.detail.successful ? parsed.message : parsed.error;
|
||||||
let targetElementId = event.detail.target.id;
|
const targetElementId = event.detail.target.id;
|
||||||
if (targetElementId != "question-count") {
|
if (targetElementId != "question-count") {
|
||||||
// WARNING: HACK
|
if (document.getElementById(targetElementId) && targetElementId.includes("question-")) {
|
||||||
// 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 = '';
|
document.getElementById(targetElementId).outerHTML = '';
|
||||||
}
|
}
|
||||||
if (msgType) {
|
if (msgType) {
|
||||||
|
|
Loading…
Add table
Reference in a new issue