2.0 - WebUI builder ("Cielight" merge) #9

Merged
Djeex merged 43 commits from beta into main 2025-08-26 10:52:13 +02:00
25 changed files with 2919 additions and 5 deletions
Showing only changes of commit 2ec4be624b - Show all commits

View File

@ -28,6 +28,30 @@ app.config["PHOTOS_DIR"] = PHOTOS_DIR
# --- Register upload blueprint --- # --- Register upload blueprint ---
app.register_blueprint(upload_bp) app.register_blueprint(upload_bp)
# --- Helper functions for theme editor ---
def get_theme_name():
site_yaml_path = Path(__file__).resolve().parents[3] / "config" / "site.yaml"
with open(site_yaml_path, "r") as f:
site_yaml = yaml.safe_load(f)
return site_yaml.get("build", {}).get("theme", "modern")
def get_theme_yaml(theme_name):
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "r") as f:
return yaml.safe_load(f)
def save_theme_yaml(theme_name, theme_yaml):
theme_yaml_path = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "theme.yaml"
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
def get_local_fonts(theme_name):
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
if not fonts_dir.exists():
return []
# Return full filenames, not just stem
return [f.name for f in fonts_dir.glob("*") if f.is_file() and f.suffix in [".woff", ".woff2"]]
# --- Routes --- # --- Routes ---
@app.route("/") @app.route("/")
@ -133,26 +157,30 @@ def delete_all_hero_photos():
@app.route("/photos/<section>/<path:filename>") @app.route("/photos/<section>/<path:filename>")
def photos(section, filename): def photos(section, filename):
"""Serve uploaded photos from disk.""" """Serve uploaded photos from disk for a specific section."""
return send_from_directory(PHOTOS_DIR / section, filename) return send_from_directory(PHOTOS_DIR / section, filename)
@app.route("/photos/<path:filename>") @app.route("/photos/<path:filename>")
def serve_photo(filename): def serve_photo(filename):
"""Serve uploaded photos from disk (generic)."""
photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos" photos_dir = Path(__file__).resolve().parents[3] / "config" / "photos"
return send_from_directory(photos_dir, filename) return send_from_directory(photos_dir, filename)
@app.route("/site-info") @app.route("/site-info")
def site_info(): def site_info():
"""Serve the site info editor page."""
return render_template("site-info/index.html") return render_template("site-info/index.html")
@app.route("/api/site-info", methods=["GET"]) @app.route("/api/site-info", methods=["GET"])
def get_site_info(): def get_site_info():
"""Return the site info YAML as JSON."""
with open(SITE_YAML, "r") as f: with open(SITE_YAML, "r") as f:
data = yaml.safe_load(f) data = yaml.safe_load(f)
return jsonify(data) return jsonify(data)
@app.route("/api/site-info", methods=["POST"]) @app.route("/api/site-info", methods=["POST"])
def update_site_info(): def update_site_info():
"""Update the site info YAML from frontend JSON."""
data = request.json data = request.json
with open(SITE_YAML, "w") as f: with open(SITE_YAML, "w") as f:
yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True) yaml.safe_dump(data, f, sort_keys=False, allow_unicode=True)
@ -160,13 +188,14 @@ def update_site_info():
@app.route("/api/themes") @app.route("/api/themes")
def list_themes(): def list_themes():
"""List available themes (folders in config/themes)."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
themes = [d.name for d in themes_dir.iterdir() if d.is_dir()] themes = [d.name for d in themes_dir.iterdir() if d.is_dir()]
return jsonify(themes) return jsonify(themes)
@app.route("/api/thumbnail/upload", methods=["POST"]) @app.route("/api/thumbnail/upload", methods=["POST"])
def upload_thumbnail(): def upload_thumbnail():
"""Upload a thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"] PHOTOS_DIR = app.config["PHOTOS_DIR"]
file = request.files.get("file") file = request.files.get("file")
if not file: if not file:
@ -183,6 +212,7 @@ def upload_thumbnail():
@app.route("/api/thumbnail/remove", methods=["POST"]) @app.route("/api/thumbnail/remove", methods=["POST"])
def remove_thumbnail(): def remove_thumbnail():
"""Remove the thumbnail image and update site.yaml."""
PHOTOS_DIR = app.config["PHOTOS_DIR"] PHOTOS_DIR = app.config["PHOTOS_DIR"]
thumbnail_path = PHOTOS_DIR / "thumbnail.png" thumbnail_path = PHOTOS_DIR / "thumbnail.png"
# Remove thumbnail file if exists # Remove thumbnail file if exists
@ -199,6 +229,7 @@ def remove_thumbnail():
@app.route("/api/theme/upload", methods=["POST"]) @app.route("/api/theme/upload", methods=["POST"])
def upload_theme(): def upload_theme():
"""Upload a custom theme folder and save it in config/themes."""
themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes" themes_dir = Path(__file__).resolve().parents[3] / "config" / "themes"
files = request.files.getlist("files") files = request.files.getlist("files")
if not files: if not files:
@ -215,7 +246,118 @@ def upload_theme():
file.save(dest_path) file.save(dest_path)
return jsonify({"status": "ok", "theme": folder_name}) return jsonify({"status": "ok", "theme": folder_name})
# --- Theme Editor API ---
@app.route("/theme-editor")
def theme_editor():
"""Serve the theme editor page."""
return render_template("theme-editor/index.html")
@app.route("/api/theme-info", methods=["GET", "POST"])
def api_theme_info():
theme_name = get_theme_name()
if request.method == "GET":
theme_yaml = get_theme_yaml(theme_name)
google_fonts = theme_yaml.get("google_fonts", [])
return jsonify({
"theme_name": theme_name,
"theme_yaml": theme_yaml,
"google_fonts": google_fonts
})
else:
data = request.get_json()
theme_yaml = data.get("theme_yaml")
theme_name = data.get("theme_name", theme_name)
save_theme_yaml(theme_name, theme_yaml)
return jsonify({"status": "ok"})
@app.route("/api/local-fonts")
def api_local_fonts():
theme_name = request.args.get("theme")
fonts = get_local_fonts(theme_name)
return jsonify(fonts)
@app.route("/api/favicon/upload", methods=["POST"])
def upload_favicon():
"""Upload favicon to theme folder and update theme.yaml."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".png", ".jpg", ".jpeg", ".ico"]:
return jsonify({"error": "Invalid file type"}), 400
filename = "favicon" + ext
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
file.save(theme_dir / filename)
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
theme_yaml.setdefault("favicon", {})["path"] = filename
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok", "filename": filename})
@app.route("/api/favicon/remove", methods=["POST"])
def remove_favicon():
"""Remove favicon from theme folder and update theme.yaml."""
data = request.get_json()
theme_name = data.get("theme")
if not theme_name:
return jsonify({"error": "Missing theme"}), 400
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name
# Remove favicon file
for ext in [".png", ".jpg", ".jpeg", ".ico"]:
favicon_path = theme_dir / f"favicon{ext}"
if favicon_path.exists():
favicon_path.unlink()
# Update theme.yaml
theme_yaml_path = theme_dir / "theme.yaml"
with open(theme_yaml_path, "r") as f:
theme_yaml = yaml.safe_load(f)
if "favicon" in theme_yaml:
theme_yaml["favicon"]["path"] = ""
with open(theme_yaml_path, "w") as f:
yaml.safe_dump(theme_yaml, f, sort_keys=False, allow_unicode=True)
return jsonify({"status": "ok"})
@app.route("/themes/<theme>/<filename>")
def serve_theme_asset(theme, filename):
theme_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme
return send_from_directory(theme_dir, filename)
@app.route("/api/font/upload", methods=["POST"])
def upload_font():
"""Upload a font file to the theme's fonts folder (only .woff/.woff2 allowed)."""
theme_name = request.form.get("theme")
file = request.files.get("file")
if not file or not theme_name:
return jsonify({"error": "Missing file or theme"}), 400
ext = Path(file.filename).suffix.lower()
if ext not in [".woff", ".woff2"]:
return jsonify({"error": "Invalid font file type"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
fonts_dir.mkdir(parents=True, exist_ok=True)
file.save(fonts_dir / file.filename)
return jsonify({"status": "ok", "filename": file.filename})
@app.route("/api/font/remove", methods=["POST"])
def remove_font():
"""Remove a font file from the theme's fonts folder."""
data = request.get_json()
theme_name = data.get("theme")
font = data.get("font")
if not theme_name or not font:
return jsonify({"error": "Missing theme or font"}), 400
fonts_dir = Path(__file__).resolve().parents[3] / "config" / "themes" / theme_name / "fonts"
font_path = fonts_dir / font
if font_path.exists():
font_path.unlink()
return jsonify({"status": "ok"})
return jsonify({"error": "Font not found"}), 404
# --- Run server --- # --- Run server ---
if __name__ == "__main__": if __name__ == "__main__":
logging.info("Starting WebUI at http://127.0.0.1:5000") logging.info("Starting WebUI at http://127.0.0.1:5000")
app.run(debug=True) app.run(debug=True)

View File

@ -29,8 +29,8 @@ document.addEventListener("DOMContentLoaded", () => {
div.style.gap = "8px"; div.style.gap = "8px";
div.style.marginBottom = "6px"; div.style.marginBottom = "6px";
div.innerHTML = ` div.innerHTML = `
<input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label"> <input type="text" placeholder="Label" value="${item.label || ""}" style="flex:1;" data-idx="${idx}" data-type="label" required>
<input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href"> <input type="text" placeholder="?tag=tag1,tag2" value="${item.href || ""}" style="flex:2;" data-idx="${idx}" data-type="href" required>
<button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button> <button type="button" class="remove-menu-item" data-idx="${idx}">🗑</button>
`; `;
menuList.appendChild(div); menuList.appendChild(div);

View File

@ -0,0 +1,393 @@
async function fetchThemeInfo() {
const res = await fetch("/api/theme-info");
return await res.json();
}
async function fetchLocalFonts(theme) {
const res = await fetch(`/api/local-fonts?theme=${encodeURIComponent(theme)}`);
return await res.json();
}
async function removeFont(theme, font) {
const res = await fetch("/api/font/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme, font })
});
return await res.json();
}
function showToast(message, type = "success", duration = 3000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
container.appendChild(toast);
requestAnimationFrame(() => toast.classList.add("show"));
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => container.removeChild(toast), 300);
}, duration);
}
function setColorInput(colorId, textId, value) {
const colorInput = document.getElementById(colorId);
const textInput = document.getElementById(textId);
if (colorInput) colorInput.value = value;
if (textInput) textInput.value = value;
if (colorInput && textInput) {
colorInput.addEventListener("input", () => {
textInput.value = colorInput.value;
});
textInput.addEventListener("input", () => {
colorInput.value = textInput.value;
});
}
}
function setFontDropdown(selectId, value, options) {
const select = document.getElementById(selectId);
if (!select) return;
select.innerHTML = options.map(opt =>
`<option value="${opt}"${opt === value ? " selected" : ""}>${opt}</option>`
).join("");
}
function setFallbackDropdown(selectId, value) {
const select = document.getElementById(selectId);
if (!select) return;
select.value = (value === "serif" || value === "sans-serif") ? value : "sans-serif";
}
function setTextInput(inputId, value) {
const input = document.getElementById(inputId);
if (input) input.value = value;
}
function renderGoogleFonts(googleFonts) {
const container = document.getElementById("google-fonts-fields");
container.innerHTML = "";
googleFonts.forEach((font, idx) => {
container.innerHTML += `
<div class="input-field" data-idx="${idx}">
<label>Family</label>
<input type="text" name="google_fonts[${idx}][family]" value="${font.family || ""}">
<label>Weights (comma separated)</label>
<input type="text" name="google_fonts[${idx}][weights]" value="${(font.weights || []).join(',')}">
<button type="button" class="remove-google-font" data-idx="${idx}">Remove</button>
</div>
`;
});
}
function renderLocalFonts(fonts) {
const listDiv = document.getElementById("local-fonts-list");
if (!listDiv) return;
listDiv.innerHTML = "";
fonts.forEach(font => {
listDiv.innerHTML += `
<div class="font-item">
<span>${font}</span>
<button type="button" class="remove-font-btn danger" data-font="${font}">Remove</button>
</div>
`;
});
}
document.addEventListener("DOMContentLoaded", async () => {
const themeInfo = await fetchThemeInfo();
const themeNameSpan = document.getElementById("current-theme");
if (themeNameSpan) themeNameSpan.textContent = themeInfo.theme_name;
const themeYaml = themeInfo.theme_yaml;
const googleFonts = themeYaml.google_fonts ? JSON.parse(JSON.stringify(themeYaml.google_fonts)) : [];
let localFonts = await fetchLocalFonts(themeInfo.theme_name);
// Colors
if (themeYaml.colors) {
setColorInput("color-primary", "color-primary-text", themeYaml.colors.primary || "#0065a1");
setColorInput("color-primary-dark", "color-primary-dark-text", themeYaml.colors.primary_dark || "#005384");
setColorInput("color-secondary", "color-secondary-text", themeYaml.colors.secondary || "#00b0f0");
setColorInput("color-accent", "color-accent-text", themeYaml.colors.accent || "#ffc700");
setColorInput("color-text-dark", "color-text-dark-text", themeYaml.colors.text_dark || "#616161");
setColorInput("color-background", "color-background-text", themeYaml.colors.background || "#fff");
setColorInput("color-browser-color", "color-browser-color-text", themeYaml.colors.browser_color || "#fff");
}
// Fonts
function refreshFontDropdowns() {
setFontDropdown("font-primary", document.getElementById("font-primary").value, [
...googleFonts.map(f => f.family),
...localFonts
]);
setFontDropdown("font-secondary", document.getElementById("font-secondary").value, [
...googleFonts.map(f => f.family),
...localFonts
]);
}
if (themeYaml.fonts) {
setFontDropdown("font-primary", themeYaml.fonts.primary?.name || "Lato", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-primary-fallback", themeYaml.fonts.primary?.fallback || "sans-serif");
setFontDropdown("font-secondary", themeYaml.fonts.secondary?.name || "Montserrat", [
...googleFonts.map(f => f.family),
...localFonts
]);
setFallbackDropdown("font-secondary-fallback", themeYaml.fonts.secondary?.fallback || "serif");
}
// Font upload logic
const fontUploadInput = document.getElementById("font-upload");
const chooseFontBtn = document.getElementById("choose-font-btn");
const fontUploadStatus = document.getElementById("font-upload-status");
const localFontsList = document.getElementById("local-fonts-list");
// Modal logic for font deletion
const deleteFontModal = document.getElementById("delete-font-modal");
const deleteFontModalClose = document.getElementById("delete-font-modal-close");
const deleteFontModalConfirm = document.getElementById("delete-font-modal-confirm");
const deleteFontModalCancel = document.getElementById("delete-font-modal-cancel");
let fontToDelete = null;
function refreshLocalFonts() {
renderLocalFonts(localFonts);
refreshFontDropdowns();
}
if (chooseFontBtn && fontUploadInput) {
chooseFontBtn.addEventListener("click", () => fontUploadInput.click());
}
if (fontUploadInput) {
fontUploadInput.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["woff", "woff2"].includes(ext)) {
fontUploadStatus.textContent = "Only .woff and .woff2 fonts are allowed.";
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("theme", themeInfo.theme_name);
const res = await fetch("/api/font/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
fontUploadStatus.textContent = "Font uploaded!";
showToast("Font uploaded!", "success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
fontUploadStatus.textContent = "Error uploading font.";
showToast("Error uploading font.", "error");
}
});
}
// Remove font button triggers modal
if (localFontsList) {
localFontsList.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-font-btn")) {
fontToDelete = e.target.dataset.font;
document.getElementById("delete-font-modal-text").textContent =
`Are you sure you want to remove the font "${fontToDelete}"?`;
deleteFontModal.style.display = "flex";
}
});
}
// Modal logic for font deletion
if (deleteFontModal && deleteFontModalClose && deleteFontModalConfirm && deleteFontModalCancel) {
deleteFontModalClose.onclick = deleteFontModalCancel.onclick = () => {
deleteFontModal.style.display = "none";
fontToDelete = null;
};
window.onclick = function(event) {
if (event.target === deleteFontModal) {
deleteFontModal.style.display = "none";
fontToDelete = null;
}
};
deleteFontModalConfirm.onclick = async () => {
if (!fontToDelete) return;
const result = await removeFont(themeInfo.theme_name, fontToDelete);
if (result.status === "ok") {
showToast("Font removed!", "success");
localFonts = await fetchLocalFonts(themeInfo.theme_name);
refreshLocalFonts();
} else {
showToast("Error removing font.", "error");
}
deleteFontModal.style.display = "none";
fontToDelete = null;
};
}
// Initial render of local fonts
refreshLocalFonts();
// Favicon logic
const faviconInput = document.getElementById("favicon-path");
const faviconUpload = document.getElementById("favicon-upload");
const chooseFaviconBtn = document.getElementById("choose-favicon-btn");
const faviconPreview = document.getElementById("favicon-preview");
const removeFaviconBtn = document.getElementById("remove-favicon-btn");
const deleteFaviconModal = document.getElementById("delete-favicon-modal");
const deleteFaviconModalClose = document.getElementById("delete-favicon-modal-close");
const deleteFaviconModalConfirm = document.getElementById("delete-favicon-modal-confirm");
const deleteFaviconModalCancel = document.getElementById("delete-favicon-modal-cancel");
function updateFaviconPreview(src) {
if (faviconPreview) {
faviconPreview.src = src || "";
faviconPreview.style.display = src ? "inline-block" : "none";
}
if (removeFaviconBtn) {
removeFaviconBtn.style.display = src ? "inline-block" : "none";
}
if (chooseFaviconBtn) {
chooseFaviconBtn.style.display = src ? "none" : "inline-block";
}
}
if (chooseFaviconBtn && faviconUpload) {
chooseFaviconBtn.addEventListener("click", () => faviconUpload.click());
}
if (faviconUpload) {
faviconUpload.addEventListener("change", async (e) => {
const file = e.target.files[0];
if (!file) return;
const ext = file.name.split('.').pop().toLowerCase();
if (!["png", "jpg", "jpeg", "ico"].includes(ext)) {
showToast("Invalid file type for favicon.", "error");
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("theme", themeInfo.theme_name);
const res = await fetch("/api/favicon/upload", { method: "POST", body: formData });
const result = await res.json();
if (result.status === "ok") {
faviconInput.value = result.filename;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${result.filename}?t=${Date.now()}`);
showToast("Favicon uploaded!", "success");
} else {
showToast("Error uploading favicon", "error");
}
});
}
if (removeFaviconBtn) {
removeFaviconBtn.addEventListener("click", () => {
deleteFaviconModal.style.display = "flex";
});
}
if (deleteFaviconModal && deleteFaviconModalClose && deleteFaviconModalConfirm && deleteFaviconModalCancel) {
deleteFaviconModalClose.onclick = deleteFaviconModalCancel.onclick = () => {
deleteFaviconModal.style.display = "none";
};
window.onclick = function(event) {
if (event.target === deleteFaviconModal) {
deleteFaviconModal.style.display = "none";
}
};
deleteFaviconModalConfirm.onclick = async () => {
const res = await fetch("/api/favicon/remove", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme: themeInfo.theme_name })
});
const result = await res.json();
if (result.status === "ok") {
faviconInput.value = "";
updateFaviconPreview("");
showToast("Favicon removed!", "success");
} else {
showToast("Error removing favicon", "error");
}
deleteFaviconModal.style.display = "none";
};
}
if (themeYaml.favicon && themeYaml.favicon.path) {
faviconInput.value = themeYaml.favicon.path;
updateFaviconPreview(`/themes/${themeInfo.theme_name}/${themeYaml.favicon.path}?t=${Date.now()}`);
} else {
updateFaviconPreview("");
}
// Google Fonts
renderGoogleFonts(googleFonts);
// Add Google Font
const addGoogleFontBtn = document.getElementById("add-google-font");
if (addGoogleFontBtn) {
addGoogleFontBtn.addEventListener("click", () => {
googleFonts.push({ family: "", weights: [] });
renderGoogleFonts(googleFonts);
});
}
// Remove Google Font
const googleFontsFields = document.getElementById("google-fonts-fields");
if (googleFontsFields) {
googleFontsFields.addEventListener("click", (e) => {
if (e.target.classList.contains("remove-google-font")) {
const idx = parseInt(e.target.dataset.idx, 10);
googleFonts.splice(idx, 1);
renderGoogleFonts(googleFonts);
}
});
}
// Form submit
document.getElementById("theme-editor-form").addEventListener("submit", async (e) => {
e.preventDefault();
const data = {};
data.colors = {
primary: document.getElementById("color-primary-text").value,
primary_dark: document.getElementById("color-primary-dark-text").value,
secondary: document.getElementById("color-secondary-text").value,
accent: document.getElementById("color-accent-text").value,
text_dark: document.getElementById("color-text-dark-text").value,
background: document.getElementById("color-background-text").value,
browser_color: document.getElementById("color-browser-color-text").value
};
data.fonts = {
primary: {
name: document.getElementById("font-primary").value,
fallback: document.getElementById("font-primary-fallback").value
},
secondary: {
name: document.getElementById("font-secondary").value,
fallback: document.getElementById("font-secondary-fallback").value
}
};
data.favicon = {
path: faviconInput.value
};
data.google_fonts = [];
document.querySelectorAll("#google-fonts-fields .input-field").forEach(field => {
const family = field.querySelector('input[name^="google_fonts"][name$="[family]"]').value;
const weights = field.querySelector('input[name^="google_fonts"][name$="[weights]"]').value
.split(",").map(w => w.trim()).filter(w => w);
if (family) data.google_fonts.push({ family, weights });
});
const res = await fetch("/api/theme-info", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ theme_name: themeInfo.theme_name, theme_yaml: data })
});
if (res.ok) {
showToast("Theme saved!", "success");
} else {
showToast("Error saving theme.", "error");
}
});
});

View File

@ -49,27 +49,27 @@
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Title</label> <label>Title</label>
<input type="text" name="info.title" placeholder="Your site title"> <input type="text" name="info.title" placeholder="Your site title" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Subtitle</label> <label>Subtitle</label>
<input type="text" name="info.subtitle" placeholder="Your site subtitle"> <input type="text" name="info.subtitle" placeholder="Your site subtitle" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Description</label> <label>Description</label>
<input type="text" name="info.description" placeholder="Your site description"> <input type="text" name="info.description" placeholder="Your site description" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Canonical URL</label> <label>Canonical URL</label>
<input type="text" name="info.canonical" placeholder="https://yoursite.com"> <input type="text" name="info.canonical" placeholder="https://yoursite.com" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Keywords (comma separated)</label> <label>Keywords (comma separated)</label>
<input type="text" name="info.keywords" placeholder="photo, gallery, photography"> <input type="text" name="info.keywords" placeholder="photo, gallery, photography" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Author</label> <label>Author</label>
<input type="text" name="info.author" placeholder="Your Name"> <input type="text" name="info.author" placeholder="Your Name" required>
</div> </div>
</div> </div>
</fieldset> </fieldset>
@ -79,9 +79,9 @@
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Instagram URL</label> <label>Instagram URL</label>
<input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile"> <input type="text" name="social.instagram_url" placeholder="https://instagram.com/yourprofile" required>
<label class="thumbnail-form-label">Thumbnail</label> <label class="thumbnail-form-label">Thumbnail</label>
<input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;"> <input type="file" id="thumbnail-upload" accept="image/png,image/jpeg,image/webp" style="display:none;" required>
<button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button> <button type="button" id="choose-thumbnail-btn" class="up-btn">📸 Upload a photo</button>
<div class="thumbnail-form"> <div class="thumbnail-form">
<img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;"> <img id="thumbnail-preview" src="" alt="Thumbnail preview" style="max-width:100px;display:none;">
@ -106,11 +106,11 @@
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Copyright</label> <label>Copyright</label>
<input type="text" name="footer.copyright"> <input type="text" name="footer.copyright" required>
</div> </div>
<div class="input-field"> <div class="input-field">
<label>Legal Label</label> <label>Legal Label</label>
<input type="text" name="footer.legal_label"> <input type="text" name="footer.legal_label" re>
</div> </div>
</div> </div>
</fieldset> </fieldset>
@ -143,7 +143,7 @@
<div class="fields"> <div class="fields">
<div class="input-field"> <div class="input-field">
<label>Theme</label> <label>Theme</label>
<select name="build.theme" id="theme-select"></select> <select name="build.theme" id="theme-select" required></select>
<input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;"> <input type="file" id="theme-upload" webkitdirectory directory multiple style="display:none;">
<button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button> <button type="button" id="choose-theme-btn" class="up-btn">📂 Upload custom theme folder</button>
<label class="thumbnail-form-label">Images processing</label> <label class="thumbnail-form-label">Images processing</label>

View File

@ -48,7 +48,7 @@ h2 {
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
border: 1px solid #2f2e2e80; border: 1px solid #2f2e2e80;
border-radius: 8px; border-radius: 8px;
padding: 0px 20px; padding: 0px 20px 20px 20px;
} }
.upload-section label { .upload-section label {
@ -491,7 +491,7 @@ h2 {
padding: 0 40px 40px 40px; padding: 0 40px 40px 40px;
} }
#site-info-form fieldset { fieldset {
background-color: rgb(67 67 67 / 26%); background-color: rgb(67 67 67 / 26%);
border-radius: 6px; border-radius: 6px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
@ -501,7 +501,7 @@ h2 {
margin: 32px auto; margin: 32px auto;
} }
#site-info-form legend { legend {
font-size: 1.2em; font-size: 1.2em;
font-weight: 700; font-weight: 700;
color: #26c4ff; color: #26c4ff;
@ -509,13 +509,13 @@ h2 {
letter-spacing: 1px; letter-spacing: 1px;
} }
#site-info-form .fields { .fields {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 18px; gap: 18px;
} }
#site-info-form .input-field { .input-field {
flex: 1 1 calc(33.333% - 18px); flex: 1 1 calc(33.333% - 18px);
min-width: 220px; min-width: 220px;
max-width: 100%; max-width: 100%;
@ -525,7 +525,7 @@ h2 {
margin-bottom: 10px; margin-bottom: 10px;
} }
#site-info-form label { label {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #e3e3e3; color: #e3e3e3;
@ -533,9 +533,9 @@ h2 {
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
#site-info-form input, #site-info-form input, #theme-editor-form input,
#site-info-form textarea, #site-info-form textarea, #theme-editor-form textarea,
#site-info-form select { #site-info-form select, #theme-editor-form select {
/* background: rgba(4, 44, 60, 0.55);*/ /* background: rgba(4, 44, 60, 0.55);*/
background: #1f2223; background: #1f2223;
color: #fff; color: #fff;
@ -551,23 +551,29 @@ h2 {
} }
#site-info-form input::placeholder, #site-info-form input::placeholder,
#site-info-form textarea::placeholder { #theme-editor-form input::placeholder,
#site-info-form textarea::placeholder,
#theme-editor-form textarea::placeholder {
color: #585858; color: #585858;
font-style: italic; font-style: italic;
} }
#site-info-form input:focus, #site-info-form input:focus,
#theme-editor-form input:focus,
#site-info-form textarea:focus, #site-info-form textarea:focus,
#site-info-form select:focus { #theme-editor-form textarea:focus,
#site-info-form select:focus,
#theme-editor-form select:focus {
border-color: #585858; border-color: #585858;
background: #161616; background: #161616;
} }
#site-info-form textarea { #site-info-form textarea,
#theme-editor-form textarea {
min-height: 60px; min-height: 60px;
resize: vertical; resize: vertical;
} }
#site-info-form input[type="file"] { #input[type="file"] {
background: none; background: none;
color: #fff; color: #fff;
border: none; border: none;
@ -575,13 +581,13 @@ h2 {
margin-top: 2px; margin-top: 2px;
} }
#site-info-form img#thumbnail-preview { img#thumbnail-preview {
margin-top: 8px; margin-top: 8px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="submit"] { #site-info-form button[type="submit"], #theme-editor-form button[type="submit"] {
background: linear-gradient(135deg, #26c4ff, #016074); background: linear-gradient(135deg, #26c4ff, #016074);
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
@ -595,11 +601,11 @@ h2 {
transition: background 0.2s; transition: background 0.2s;
} }
#site-info-form button[type="submit"]:hover { #site-info-form button[type="submit"]:hover, #theme-editor-form button[type="submit"]:hover {
background: linear-gradient(135deg, #72d9ff, #26657e); background: linear-gradient(135deg, #72d9ff, #26657e);
} }
#site-info-form button[type="button"] { #site-info-form button[type="button"], #theme-editor-form button[type="button"] {
background: #00000000; background: #00000000;
color: #fff; color: #fff;
border: none; border: none;
@ -612,47 +618,47 @@ h2 {
border: 1px solid #585858; border: 1px solid #585858;
} }
#site-info-form button[type="button"]:hover { #site-info-form button[type="button"]:hover, #theme-editor-form button[type="button"]:hover {
background: #2d2d2d; background: #2d2d2d;
color: #fff; color: #fff;
} }
@media (max-width: 900px) { @media (max-width: 900px) {
#site-info-form { #site-info-form, #theme-editor-form {
padding: 18px 8px; padding: 18px 8px;
} }
#site-info-form .fields, .fields,
#site-info-form fieldset { fieldset {
flex-direction: column; flex-direction: column;
gap: 0; gap: 0;
} }
#site-info-form .input-field { .input-field {
min-width: 100%; min-width: 100%;
margin-bottom: 12px; margin-bottom: 12px;
} }
} }
#site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph { #site-info-form button.remove-menu-item, #site-info-form button.remove-ip-paragraph, #theme-editor-form button.remove-menu-item, #theme-editor-form button.remove-ip-paragraph {
margin-top: 0px; margin-top: 0px;
margin-bottom: 4px; margin-bottom: 4px;
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover { #site-info-form button.remove-menu-item:hover, #site-info-form button.remove-ip-paragraph:hover, #theme-editor-form button.remove-menu-item:hover, #theme-editor-form button.remove-ip-paragraph:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form button.remove-btn { #site-info-form button.remove-btn, #theme-editor-form button.remove-btn {
border-radius: 30px; border-radius: 30px;
background: #2d2d2d; background: #2d2d2d;
} }
#site-info-form button.remove-btn:hover{ #site-info-form button.remove-btn:hover, #theme-editor-form button.remove-btn:hover {
background: rgb(121, 26, 19); background: rgb(121, 26, 19);
} }
#site-info-form .thumbnail-form-label { #site-info-form .thumbnail-form-label, #theme-editor-form .thumbnail-form-label {
margin-top: 10px; margin-top: 10px;
} }

View File

@ -0,0 +1,178 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
<title>Theme Editor</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style/style.css') }}">
</head>
<body>
<!-- Top bar -->
<div class="nav-bar">
<div class="content-inner nav">
<div class="nav-cta">
<div class="arrow"></div>
<a class="button" href="/site-info">
<span id="step">← Back to Site Info</span>
</a>
</div>
<input type="checkbox" id="nav-check">
<div class="nav-header">
<div class="nav-title">
<img src="../img/logo.svg">
</div>
</div>
<div class="nav-btn">
<label for="nav-check">
<span></span>
<span></span>
<span></span>
</label>
</div>
<div class="nav-links">
<ul class="nav-list">
<li class="nav-item appear2"><a href="/site-info">Site info</a></li>
<li class="nav-item appear2"><a href="/theme-editor">Theme editor</a></li>
<li class="nav-item appear2"><a href="#">Gallery</a></li>
</ul>
</div>
</div>
</div>
<!-- Toast container for notifications -->
<div id="theme-editor" class="content-inner">
<div id="toast-container"></div>
<h1>Edit Theme</h1>
<!-- Show current theme -->
<div class="theme-info">
<strong>Current theme:</strong> <span id="current-theme"></span>
</div>
<form id="theme-editor-form">
<!-- Colors Section -->
<fieldset>
<h2>Colors</h2>
<div class="fields">
<div class="input-field">
<label>Primary</label>
<input type="color" name="colors.primary" id="color-primary">
<input type="text" name="colors.primary_text" id="color-primary-text" style="width:100px;">
</div>
<div class="input-field">
<label>Primary Dark</label>
<input type="color" name="colors.primary_dark" id="color-primary-dark">
<input type="text" name="colors.primary_dark_text" id="color-primary-dark-text" style="width:100px;">
</div>
<div class="input-field">
<label>Secondary</label>
<input type="color" name="colors.secondary" id="color-secondary">
<input type="text" name="colors.secondary_text" id="color-secondary-text" style="width:100px;">
</div>
<div class="input-field">
<label>Accent</label>
<input type="color" name="colors.accent" id="color-accent">
<input type="text" name="colors.accent_text" id="color-accent-text" style="width:100px;">
</div>
<div class="input-field">
<label>Text Dark</label>
<input type="color" name="colors.text_dark" id="color-text-dark">
<input type="text" name="colors.text_dark_text" id="color-text-dark-text" style="width:100px;">
</div>
<div class="input-field">
<label>Background</label>
<input type="color" name="colors.background" id="color-background">
<input type="text" name="colors.background_text" id="color-background-text" style="width:100px;">
</div>
<div class="input-field">
<label>Browser Color</label>
<input type="color" name="colors.browser_color" id="color-browser-color">
<input type="text" name="colors.browser_color_text" id="color-browser-color-text" style="width:100px;">
</div>
</div>
</fieldset>
<!-- Google Fonts Section -->
<fieldset>
<h2>Google Fonts</h2>
<div class="fields" id="google-fonts-fields">
<!-- JS will render font family and weights inputs here -->
</div>
<button type="button" id="add-google-font">Add Google Font</button>
</fieldset>
<!-- Custom Font Upload Section -->
<fieldset>
<h2>Upload Custom Font (.woff, .woff2)</h2>
<div class="fields">
<input type="file" id="font-upload" accept=".woff,.woff2" style="display:none;">
<button type="button" id="choose-font-btn" class="up-btn">🖋️ Upload font</button>
<div id="local-fonts-list" class="font-list"></div>
<span id="font-upload-status"></span>
</div>
</fieldset>
<!-- Fonts Section -->
<fieldset>
<h2>Fonts</h2>
<div class="fields">
<div class="input-field">
<label>Primary Font</label>
<select name="fonts.primary.name" id="font-primary"></select>
<label>Fallback</label>
<select name="fonts.primary.fallback" id="font-primary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
<div class="input-field">
<label>Secondary Font</label>
<select name="fonts.secondary.name" id="font-secondary"></select>
<label>Fallback</label>
<select name="fonts.secondary.fallback" id="font-secondary-fallback">
<option value="sans-serif">sans-serif</option>
<option value="serif">serif</option>
</select>
</div>
</div>
</fieldset>
<!-- Favicon Section -->
<fieldset>
<h2>Favicon</h2>
<div class="fields">
<div class="input-field">
<label>Favicon Path</label>
<input type="text" name="favicon.path" id="favicon-path" readonly>
<input type="file" id="favicon-upload" accept=".png,.jpg,.jpeg,.ico" style="display:none;">
<button type="button" id="choose-favicon-btn" class="up-btn">🖼️ Upload favicon</button>
<div class="favicon-form">
<img id="favicon-preview" src="" alt="Favicon preview" style="max-width:48px;display:none;">
<button type="button" id="remove-favicon-btn" class="remove-btn up-btn danger" style="display:none;">Remove</button>
</div>
</div>
</div>
</fieldset>
<button type="submit">Save Theme</button>
</form>
</div>
<!-- Delete confirmation modal for favicon -->
<div id="delete-favicon-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-favicon-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-favicon-modal-text">Are you sure you want to remove this favicon?</p>
<div class="modal-actions">
<button id="delete-favicon-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-favicon-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<!-- Delete confirmation modal for font -->
<div id="delete-font-modal" class="modal" style="display:none;">
<div class="modal-content">
<span id="delete-font-modal-close" class="modal-close">&times;</span>
<h3>Confirm Deletion</h3>
<p id="delete-font-modal-text">Are you sure you want to remove this font?</p>
<div class="modal-actions">
<button id="delete-font-modal-confirm" class="modal-btn danger">Remove</button>
<button id="delete-font-modal-cancel" class="modal-btn">Cancel</button>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/theme-editor.js') }}"></script>
</body>
</html>