theme store implementation

This commit is contained in:
mst 2025-02-28 07:16:38 +03:00
parent ef0dd5b4fe
commit 820638694d
No known key found for this signature in database
2 changed files with 441 additions and 12 deletions

172
app.py
View file

@ -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'])

View file

@ -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 () {