mirror of
https://codeberg.org/catask-org/catask.git
synced 2025-04-20 05:43:41 -05:00
theme store implementation
This commit is contained in:
parent
ef0dd5b4fe
commit
820638694d
2 changed files with 441 additions and 12 deletions
172
app.py
172
app.py
|
@ -391,6 +391,164 @@ def pwaManifest():
|
||||||
"background_color": ""
|
"background_color": ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@api_bp.route('/ts/download/<author>/<theme>/', methods=['GET'])
|
||||||
|
@loginRequired
|
||||||
|
def downloadTheme(author, theme):
|
||||||
|
return send_file(
|
||||||
|
requests.get(f"{ cfg['themeStoreUrl'] }/api/v1/dl/{author}/{theme}/"),
|
||||||
|
download_name=f"{ theme }.css")
|
||||||
|
|
||||||
|
@api_bp.route('/ts/card_row/', methods=['GET'])
|
||||||
|
@loginRequired
|
||||||
|
def themeStore_themes():
|
||||||
|
cfg = func.loadJSON(const.configFile)
|
||||||
|
url = f"{ cfg['themeStoreUrl'] }/api/v1/themes/"
|
||||||
|
themes = requests.get(url).json()
|
||||||
|
cards = ""
|
||||||
|
i = 0
|
||||||
|
for theme in themes:
|
||||||
|
i += 1
|
||||||
|
cards += f"""
|
||||||
|
<div class="col-md-6 col-lg-4 col-xl-3 mb-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header p-0 preview-img">
|
||||||
|
<button type="button" class="btn border-0 p-0" data-bs-toggle="modal" data-bs-target="#theme-modal-{i}" data-dontshowtoast>
|
||||||
|
<img src="{ cfg['themeStoreUrl'] }{ theme['preview_url'] }" class="card-img-top" alt="Preview of { theme['name'] }" style="min-height: 160px; object-fit: cover;">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="card-text text-body-secondary mb-1 small">{ _("Updated") } { func.formatRelativeTime(theme['updated_at']) }</p>
|
||||||
|
<h5 class="card-title m-0"><button type="button" class="btn p-0 fs-5" data-bs-toggle="modal" data-bs-target="#theme-modal-{i}" data-dontshowtoast>{ theme['name'] }</button></h5>
|
||||||
|
<p class="card-text text-body-secondary">
|
||||||
|
{ _("by") }
|
||||||
|
<a href="{ theme['author_url'] or 'javascript:;' }" class="link-offset-2 text-body text-decoration-none underline-hover">{ theme['author'] }</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
html = f"""
|
||||||
|
<div class="row">
|
||||||
|
{cards}
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return html
|
||||||
|
|
||||||
|
@api_bp.route('/ts/modals/', methods=['GET'])
|
||||||
|
@loginRequired
|
||||||
|
def themeStore_themeModals():
|
||||||
|
cfg = func.loadJSON(const.configFile)
|
||||||
|
url = f"{cfg['themeStoreUrl']}/api/v1/themes/"
|
||||||
|
themes = requests.get(url).json()
|
||||||
|
i = 0
|
||||||
|
modals = ""
|
||||||
|
|
||||||
|
for theme in themes:
|
||||||
|
i += 1
|
||||||
|
theme_name = theme['name']
|
||||||
|
singleton = f"data-bs-toggle='modal' data-bs-target='#theme-modal-confirm-{i}'" if cfg['style']['customCss'] != "" else f"""data-bs-dismiss='modal' onclick='useTheme({i}, "{ theme_name }")'"""
|
||||||
|
modals += f"""
|
||||||
|
<div class="modal fade" id="theme-modal-{i}" tabindex="-1" aria-labelledby="ts-modal-label" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-fullscreen-md-down modal-xl modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header border-0 d-flex align-items-center ps-2 ms-1">
|
||||||
|
<h1 class="modal-title fs-5 fw-normal d-flex align-items-center" id="q-modal-label">
|
||||||
|
<button type="button" class="btn btn-basic fs-5 me-1 px-2 py-1" data-bs-target="#theme-store-modal" data-bs-toggle="modal">
|
||||||
|
<i class="bi bi-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<span>{ theme['name'] } <span class="text-body-secondary">{ _("by") } { theme['author'] }</span></span>
|
||||||
|
</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">
|
||||||
|
<section id="preview">
|
||||||
|
<img src="{ cfg['themeStoreUrl'] }{ theme['preview_url'] }" class="img-fluid rounded mx-auto d-block" alt="Screenshot of { theme['name'] } by { theme['author'] }">
|
||||||
|
</section>
|
||||||
|
<section id="header">
|
||||||
|
<div class="d-md-flex justify-content-between align-items-center mb-4 mt-3">
|
||||||
|
<div class="d-flex">
|
||||||
|
<span class="border py-1 px-2 rounded-start user-select-all overflow-hidden text-nowrap ts-share">{ cfg['themeStoreUrl'] }/themes/{ theme['escaped_name'] }/</span>
|
||||||
|
<button class="btn btn-secondary rounded-start-0" type="button" onclick="copyFull(`{ cfg['themeStoreUrl'] }/themes/{ theme['escaped_name'] }/`)" style="min-width: 6em;"><i class="bi bi-copy me-1"></i> { _("Copy") }</button>
|
||||||
|
</div>
|
||||||
|
<br class="d-md-none">
|
||||||
|
<div class="d-flex flex-column flex-md-row align-items-md-center gap-2">
|
||||||
|
<button type="button" class="btn btn-primary" {singleton}><i class="bi bi-palette me-1"></i> { _("Use") }</button>
|
||||||
|
{ f"""<a href="{ theme['homepage'] }/" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-house-door"></i>
|
||||||
|
<span class="d-inline d-sm-none ms-1">{ _("Homepage") }</span>
|
||||||
|
</a>""" if theme['homepage'] else "" }
|
||||||
|
<a href="{ cfg['themeStoreUrl'] }/dl/{ theme['author'] }/{ theme['name'] }/" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-download"></i>
|
||||||
|
<span class="d-inline d-sm-none ms-1">{ _("Download") }</span>
|
||||||
|
</a>
|
||||||
|
<a href="{ cfg['themeStoreUrl'] }/themes/{ theme['escaped_name'] }/" class="btn btn-secondary" target="_blank">
|
||||||
|
<i class="bi bi-box-arrow-up-right"></i>
|
||||||
|
<span class="d-inline d-sm-none ms-1">{ _("Go to Store page") }</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="details" class="mb-4">
|
||||||
|
<h2>{ _("Details") }</h2>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="tab">{ _("Author") }</span>
|
||||||
|
<a href="{ theme['author_url'] }" target="_blank" class="text-decoration-none underline-hover">{ theme['author'] }</a>
|
||||||
|
</p>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="tab">{ _("Size") }</span>
|
||||||
|
{ theme['size'] }
|
||||||
|
</p>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="tab">{ _("Created") }</span>
|
||||||
|
{ func.formatRelativeTime(theme['created_at']) }
|
||||||
|
</p>
|
||||||
|
<p class="mb-2">
|
||||||
|
<span class="tab">{ _("Updated") }</span>
|
||||||
|
{ func.formatRelativeTime(theme['updated_at']) }
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section id="source-code">
|
||||||
|
<h2>{ _("Source code") }</h2>
|
||||||
|
<pre class="border p-2 rounded"><code style="tab-size: 4;" id="theme-source-code-{i}">{ theme['file_contents'] }</code></pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal fade" id="theme-modal-confirm-{i}" tabindex="-1" aria-labelledby="ts-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">{ _('Confirmation') }</h1>
|
||||||
|
<button type="button" class="btn-close d-flex align-items-center fs-5" data-bs-toggle='modal' data-bs-target='#theme-modal-{i}' aria-label="Close"><i class="bi bi-x-lg"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body py-0">
|
||||||
|
<p>{ _('This action will overwrite your existing custom CSS, are you sure you want to apply this theme?') }</p>
|
||||||
|
</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-toggle='modal' data-bs-target='#theme-modal-{i}'>{ _('Cancel') }</button>
|
||||||
|
<button type="button" class="btn btn-danger flex-fill flex-lg-grow-0" data-bs-dismiss="modal" onclick="useTheme({ i }, '{ theme_name }')">{ _('Confirm') }</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
return modals
|
||||||
|
|
||||||
|
@api_bp.route("/change_language/", methods=['POST'])
|
||||||
|
def changeLanguage():
|
||||||
|
if cfg['languages']['allowChanging']:
|
||||||
|
lang = request.form.get('lang')
|
||||||
|
if lang not in app.config['available_languages'].keys():
|
||||||
|
# fallback to en_US on malformed request
|
||||||
|
session['language'] == 'en_US'
|
||||||
|
flash("400 Bad Request: The browser (or proxy) sent a request that this server could not understand.", "danger")
|
||||||
|
return redirect(request.referrer)
|
||||||
|
session['language'] = lang
|
||||||
|
return redirect(request.referrer)
|
||||||
|
else:
|
||||||
|
return abort(404)
|
||||||
|
|
||||||
# wip, scheduled for 1.8.0 release
|
# wip, scheduled for 1.8.0 release
|
||||||
# @api_bp.route('/hyper/widget/', methods=['GET'])
|
# @api_bp.route('/hyper/widget/', methods=['GET'])
|
||||||
# def widget():
|
# def widget():
|
||||||
|
@ -399,6 +557,20 @@ def pwaManifest():
|
||||||
# metadata = func_val[1]
|
# metadata = func_val[1]
|
||||||
# return render_template('widget.html', combined=combined, urllib=urllib, trimContent=func.trimContent, metadata=metadata, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime)
|
# return render_template('widget.html', combined=combined, urllib=urllib, trimContent=func.trimContent, metadata=metadata, getRandomWord=func.getRandomWord, formatRelativeTime=func.formatRelativeTime)
|
||||||
|
|
||||||
|
@api_bp.route('/hyper/load_more_questions/', methods=['GET'])
|
||||||
|
def load_more_questions():
|
||||||
|
page = request.args.get('page', default=1, type=int)
|
||||||
|
per_page = 25
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
|
func_val = func.getAllQuestions(limit=per_page, offset=offset)
|
||||||
|
combined = func_val[0]
|
||||||
|
|
||||||
|
if not combined:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
return render_template('snippets/layout/load_more_questions.html', combined=combined, page=page, per_page=per_page, formatRelativeTime=func.formatRelativeTime, trimContent=func.trimContent, urllib=urllib)
|
||||||
|
|
||||||
# -- question routes --
|
# -- question routes --
|
||||||
|
|
||||||
@api_bp.route('/add_question/', methods=['POST'])
|
@api_bp.route('/add_question/', methods=['POST'])
|
||||||
|
|
|
@ -143,33 +143,290 @@
|
||||||
Retrospring
|
Retrospring
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="fw-light">Info box layout</h3>
|
<h3 class="fw-light">{{ _('Info box layout') }}</h3>
|
||||||
<div class="btn-group mb-4 w-100" role="group" aria-label="Info box layout group">
|
<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 %}>
|
<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">
|
<label class="btn btn-outline-secondary w-50" for="style.infoBoxLayout.column">
|
||||||
Column
|
{{ _('Column') }}
|
||||||
</label>
|
</label>
|
||||||
<input class="btn-check" type="radio" name="style.infoBoxLayout" id="style.infoBoxLayout.row" value="row" {% if cfg.style.infoBoxLayout == 'row' %}checked{% endif %}>
|
<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">
|
<label class="btn btn-outline-secondary w-50" for="style.infoBoxLayout.row">
|
||||||
Row
|
{{ _('Row') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="fw-light">Trimmed content</h3>
|
<h3 class="fw-light">{{ _('Trimmed content') }}</h3>
|
||||||
<div class="form-group mb-3">
|
<div class="form-group mb-3">
|
||||||
<label class="form-label" for="trimContentAfter">Trim content after (characters)</label>
|
<label class="form-label" for="trimContentAfter">{{ _('Trim content after (characters)') }}</label>
|
||||||
<input type="number" id="trimContentAfter" name="trimContentAfter" value="{{ cfg.trimContentAfter }}" class="form-control">
|
<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>
|
<p class="form-text">{{ _('Maximum length of content before it gets trimmed (used in sharing options); set to 0 to disable') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<button type="submit" class="btn btn-primary mt-3" id="saveConfig">
|
<h3 class="fw-light">{{ _('Advanced') }}</h3>
|
||||||
<span class="spinner-border spinner-border-sm htmx-indicator" aria-hidden="true"></span>
|
<h4 class="fw-light">{{ _('Custom CSS') }}</h4>
|
||||||
<span class="visually-hidden" role="status">Loading...</span>
|
<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>
|
||||||
Save
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block _scripts %}
|
{% 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>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue