From 311561c62dcb7aedfd0da1e3fa4f866bbc78bd9f Mon Sep 17 00:00:00 2001 From: ION606 Date: Sun, 16 Feb 2025 11:24:17 -0500 Subject: [PATCH] added better editing --- .gitignore | 1 + app.py | 375 +++++++++++++++++++-------------------- static/editor.js | 152 +++++++++++----- static/styles.css | 40 +++++ templates/dashboard.html | 13 +- templates/edit.html | 56 +++--- 6 files changed, 377 insertions(+), 260 deletions(-) diff --git a/.gitignore b/.gitignore index ed5888b..38addf8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .venv instance sites +__pycache__ diff --git a/app.py b/app.py index 56521e5..faec216 100644 --- a/app.py +++ b/app.py @@ -1,21 +1,22 @@ from flask import ( - Flask, - render_template, - request, - redirect, - url_for, - send_from_directory, - flash, - abort, + Flask, + render_template, + request, + redirect, + url_for, + send_from_directory, + flash, + abort, + jsonify, ) from flask_sqlalchemy import SQLAlchemy from flask_login import ( - LoginManager, - UserMixin, - login_user, - logout_user, - login_required, - current_user, + LoginManager, + UserMixin, + login_user, + logout_user, + login_required, + current_user, ) from flask_apscheduler import APScheduler from werkzeug.security import generate_password_hash, check_password_hash @@ -35,23 +36,24 @@ db = SQLAlchemy(app) # Models class User(UserMixin, db.Model): - id = db.Column(db.Integer, primary_key=True) - email = db.Column(db.String(100), unique=True) - password_hash = db.Column(db.String(128)) # Renamed for clarity + id = db.Column(db.Integer, primary_key=True) + email = db.Column(db.String(100), unique=True) + password_hash = db.Column(db.String(128)) # Renamed for clarity - def set_password(self, password): - self.password_hash = generate_password_hash(password) + def set_password(self, password): + self.password_hash = generate_password_hash(password) - def check_password(self, password): - return check_password_hash(self.password_hash, password) + def check_password(self, password): + return check_password_hash(self.password_hash, password) class Site(db.Model): - id = db.Column(db.Integer, primary_key=True) - user_id = db.Column(db.Integer) - name = db.Column(db.String(100)) - last_accessed = db.Column(db.DateTime) - created_at = db.Column(db.DateTime, default=datetime.utcnow) + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer) + name = db.Column(db.String(100)) + slug = db.Column(db.String(100), unique=True) + last_accessed = db.Column(db.DateTime) + created_at = db.Column(db.DateTime, default=datetime.utcnow) # Auth setup @@ -62,7 +64,7 @@ login_manager.login_view = "login" @login_manager.user_loader def load_user(user_id): - return User.query.get(int(user_id)) + return User.query.get(int(user_id)) # Scheduler for auto-deletion @@ -71,244 +73,241 @@ scheduler.init_app(app) def delete_inactive_sites(): - with app.app_context(): - cutoff = datetime.utcnow() - timedelta(days=30) - sites = Site.query.filter(Site.last_accessed < cutoff).all() - for site in sites: - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) - ) - if os.path.exists(site_dir): - shutil.rmtree(site_dir) - db.session.delete(site) - db.session.commit() + with app.app_context(): + cutoff = datetime.utcnow() - timedelta(days=30) + sites = Site.query.filter(Site.last_accessed < cutoff).all() + for site in sites: + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) + ) + if os.path.exists(site_dir): + shutil.rmtree(site_dir) + db.session.delete(site) + db.session.commit() if not scheduler.running: - scheduler.start() - scheduler.add_job( - id="delete_job", func=delete_inactive_sites, trigger="interval", days=1 - ) + scheduler.start() + scheduler.add_job( + id="delete_job", func=delete_inactive_sites, trigger="interval", days=1 + ) # Routes @app.route("/login", methods=["GET", "POST"]) def login(): - if request.method == "POST": - email = request.form.get("email") - password = request.form.get("password") - user = User.query.filter_by(email=email).first() + if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") + user = User.query.filter_by(email=email).first() - if user and user.check_password(password): - login_user(user) - return redirect(url_for("dashboard")) - flash("Invalid email or password") - return render_template("login.html") + if user and user.check_password(password): + login_user(user) + return redirect(url_for("dashboard")) + flash("Invalid email or password") + return render_template("login.html") @app.route("/logout") def logout(): - logout_user() - return redirect(url_for("home")) + logout_user() + return redirect(url_for("home")) @app.route("/register", methods=["GET", "POST"]) def register(): - if request.method == "POST": - email = request.form.get("email") - password = request.form.get("password") + if request.method == "POST": + email = request.form.get("email") + password = request.form.get("password") - if User.query.filter_by(email=email).first(): - flash("Email already exists") - return redirect(url_for("register")) + if User.query.filter_by(email=email).first(): + flash("Email already exists") + return redirect(url_for("register")) - new_user = User(email=email) - new_user.set_password(password) - db.session.add(new_user) - db.session.commit() + new_user = User(email=email) + new_user.set_password(password) + db.session.add(new_user) + db.session.commit() - login_user(new_user) - return redirect(url_for("dashboard")) - return render_template("register.html") + login_user(new_user) + return redirect(url_for("dashboard")) + return render_template("register.html") @app.route("/dashboard") @login_required def dashboard(): - sites = Site.query.filter_by(user_id=current_user.id).all() - return render_template("dashboard.html", sites=sites) + sites = Site.query.filter_by(user_id=current_user.id).all() + return render_template("dashboard.html", sites=sites) @app.route("/upload", methods=["POST"]) @login_required def upload_site(): - site_name = request.form.get("name") - if not site_name: - flash("Site name is required", "error") - return redirect(url_for("dashboard")) + site_name = request.form.get("name") + slug = request.form.get("slug").strip() - # Check if index.html is included - files = request.files.getlist("files") - if not any(file.filename == "index.html" for file in files): - flash("You must include an index.html file", "error") - return redirect(url_for("dashboard")) + if not site_name or not slug: + flash("Site name and URL slug are required", "error") + return redirect(url_for("dashboard")) - # Create site directory - site = Site(user_id=current_user.id, name=site_name) - db.session.add(site) - db.session.commit() + # Check slug availability + if Site.query.filter_by(slug=slug).first(): + flash("This URL is already taken", "error") + return redirect(url_for("dashboard")) - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - os.makedirs(site_dir, exist_ok=True) + # Check if index.html is included + files = request.files.getlist("files") + if not any(file.filename == "index.html" for file in files): + flash("You must include an index.html file", "error") + return redirect(url_for("dashboard")) - # Save uploaded files - for file in files: - if file.filename == "": - continue - file.save(os.path.join(site_dir, file.filename)) + # Create site directory + site = Site(user_id=current_user.id, name=site_name, slug=slug) + db.session.add(site) + db.session.commit() - flash("Site created successfully!", "success") - return redirect(url_for("dashboard")) + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + os.makedirs(site_dir, exist_ok=True) + + # Save uploaded files + for file in files: + if file.filename == "": + continue + file.save(os.path.join(site_dir, file.filename)) + + flash("Site created successfully!", "success") + return redirect(url_for("dashboard")) @app.route("/edit/", methods=["GET", "POST"]) @login_required def edit_site(site_id): - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) - if request.method == "POST": - site.name = request.form.get("name", site.name) - db.session.commit() + if request.method == "POST": + site.name = request.form.get("name", site.name) + db.session.commit() - for filename, content in request.form.items(): - if filename.endswith((".html", ".css", ".js")): - filepath = os.path.join(site_dir, filename) - with open(filepath, "w") as f: - f.write(content) + for filename, content in request.form.items(): + if filename.endswith((".html", ".css", ".js")): + filepath = os.path.join(site_dir, filename) + with open(filepath, "w") as f: + f.write(content) - return redirect(url_for("dashboard")) + if request.headers.get("X-Requested-With") == "XMLHttpRequest": + return jsonify({"success": True, "message": "Site updated successfully!"}) - files = {} - for file in os.listdir(site_dir): - with open(os.path.join(site_dir, file), "r") as f: - files[file] = f.read() + flash("Site updated successfully!", "success") + return redirect(url_for("edit_site", site_id=site.id)) - return render_template("edit.html", site=site, files=files) + files = {} + for file in os.listdir(site_dir): + with open(os.path.join(site_dir, file), "r") as f: + files[file] = f.read() + + return render_template("edit.html", site=site, files=files) @app.route("/delete/", methods=["POST"]) @login_required def delete_site(site_id): - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - if os.path.exists(site_dir): - shutil.rmtree(site_dir) + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + if os.path.exists(site_dir): + shutil.rmtree(site_dir) - db.session.delete(site) - db.session.commit() - return redirect(url_for("dashboard")) + db.session.delete(site) + db.session.commit() + return redirect(url_for("dashboard")) @app.route("/delete_file//", methods=["POST"]) @login_required def delete_file(site_id, filename): - # Get the site and verify ownership - site = Site.query.get_or_404(site_id) - if site.user_id != current_user.id: - return "Unauthorized", 403 + # Get the site and verify ownership + site = Site.query.get_or_404(site_id) + if site.user_id != current_user.id: + return "Unauthorized", 403 - # Build the file path - site_dir = os.path.join( - app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) - ) - file_path = os.path.join(site_dir, filename) + # Build the file path + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(current_user.id), str(site.id) + ) + file_path = os.path.join(site_dir, filename) - # Delete the file if it exists - if os.path.exists(file_path): - os.remove(file_path) - flash(f"File '{filename}' deleted successfully!", "success") - else: - flash(f"File '{filename}' not found!", "error") + # Delete the file if it exists + if os.path.exists(file_path): + os.remove(file_path) + flash(f"File '{filename}' deleted successfully!", "success") + else: + flash(f"File '{filename}' not found!", "error") - return redirect(url_for("dashboard")) + return redirect(url_for("dashboard")) -@app.route("/site//") -def redirect_to_slash(user_id, site_id): - """Redirect URLs without trailing slash to the slash version""" - return redirect( - url_for( - "serve_site_content", - user_id=user_id, - site_id=site_id, - filename="index.html", - ), - code=301, - ) +@app.route("/site//", defaults={"filename": "index.html"}) +@app.route("/site//") +def serve_site_content(slug, filename): + site = Site.query.filter_by(slug=slug).first_or_404() + site.last_accessed = datetime.utcnow() + db.session.commit() + site_dir = os.path.join( + app.config["UPLOAD_FOLDER"], str(site.user_id), str(site.id) + ) -@app.route("/site///", defaults={"filename": "index.html"}) -@app.route("/site///") -def serve_site_content(user_id, site_id, filename): - # Update last accessed time - site = Site.query.get_or_404(site_id) - site.last_accessed = datetime.utcnow() - db.session.commit() + # Security check + if ".." in filename or filename.startswith("/") or not os.path.exists(site_dir): + abort(404) - # Get site directory - site_dir = os.path.join(app.config["UPLOAD_FOLDER"], str(user_id), str(site_id)) - - # Security check - if '..' in filename or filename.startswith('/') or not os.path.exists(site_dir): - abort(404) - - try: - # First try to serve requested file - return send_from_directory(site_dir, filename) - except NotFound: - # Handle extensionless URLs and SPA-style routing - if "." not in filename: - # Try with .html extension - try: - return send_from_directory(site_dir, f"{filename}.html") - except NotFound: - # Fallback to index.html for client-side routing - return send_from_directory(site_dir, "index.html") - abort(404) + try: + # First try to serve requested file + return send_from_directory(site_dir, filename) + except NotFound: + # Handle extensionless URLs and SPA-style routing + if "." not in filename: + # Try with .html extension + try: + return send_from_directory(site_dir, f"{filename}.html") + except NotFound: + # Fallback to index.html for client-side routing + return send_from_directory(site_dir, "index.html") + abort(404) def list_files(directory): - try: - return os.listdir(directory) - except FileNotFoundError: - return [] + try: + return os.listdir(directory) + except FileNotFoundError: + return [] @app.context_processor def inject_utilities(): - return dict(list_files=list_files) + return dict(list_files=list_files) @app.route("/") def home(): - return render_template("home.html") + return render_template("home.html") if __name__ == "__main__": - os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) - with app.app_context(): - db.create_all() - serve(app, host="0.0.0.0", port=5121) + os.makedirs(app.config["UPLOAD_FOLDER"], exist_ok=True) + with app.app_context(): + db.create_all() + serve(app, host="0.0.0.0", port=5121) diff --git a/static/editor.js b/static/editor.js index f7a0e46..0d5413c 100644 --- a/static/editor.js +++ b/static/editor.js @@ -11,54 +11,74 @@ function formatOption(option) { } +function getAceMode(language) { + let aceMode = "ace/mode/html"; // default fallback + if (language === "css") { + aceMode = "ace/mode/css"; + } else if (language === "javascript") { + aceMode = "ace/mode/javascript"; + } else if (language === "htmlmixed") { + aceMode = "ace/mode/html"; + } + return aceMode; +} + document.addEventListener("DOMContentLoaded", () => { const storedTheme = localStorage.getItem('editortheme'); - // for each textarea (one per file) replace with Ace Editor - const editors = Array.from(document.querySelectorAll('textarea')).map((textarea) => { - const language = textarea.dataset.language, - themeStyle = document.querySelector('#theme-style'), - isDark = themeStyle.href.includes('dark-styles.css'), - aceTheme = isDark ? "ace/theme/twilight" : "ace/theme/chrome", - editorContainer = document.createElement('div'); + // Initialize Ace editor instance on the container + const editor = ace.edit("editor-container"); + editor.setTheme("ace/theme/chrome"); - editorContainer.style.width = "100%"; - editorContainer.style.height = "400px"; + // Default mode as html mixed. It will be updated based on file type. + editor.session.setMode("ace/mode/html"); - // insert the container after the textarea - textarea.parentNode.insertBefore(editorContainer, textarea.nextSibling); - // hide the original textarea - textarea.style.display = "none"; + // To track the current file (index) + var currentFileIndex = null; - const editor = ace.edit(editorContainer); - editor.setTheme(storedTheme || aceTheme); + // Returns Ace mode based on file extension + function getAceMode(filename) { + if (filename.endsWith('.css')) return "ace/mode/css"; + if (filename.endsWith('.js')) return "ace/mode/javascript"; + return "ace/mode/html"; + } - let aceMode = "ace/mode/html"; // default fallback - if (language === "css") { - aceMode = "ace/mode/css"; - } else if (language === "javascript") { - aceMode = "ace/mode/javascript"; - } else if (language === "htmlmixed") { - aceMode = "ace/mode/html"; + // When a file card is clicked then load its content into the editor + document.querySelectorAll('#file-list .file-card').forEach((card) => { + card.addEventListener('click', () => { + // Save changes of currently open file + if (currentFileIndex !== null) document.getElementById(`textarea-${currentFileIndex}`).value = editor.getValue(); + currentFileIndex = card.getAttribute("data-index"); + + // Get filename and load content from corresponding hidden textarea + const filename = card.getAttribute("data-file"); + editor.setValue(document.getElementById(`textarea-${currentFileIndex}`).value); + editor.session.setMode(getAceMode(filename)); + + // Show the editor container + document.getElementById("editor-container").classList.add("visible"); + }); + }); + + // Update the corresponding hidden textarea whenever the editor value changes + editor.session.on("change", function (e) { + if (currentFileIndex !== null) { + const ta = document.getElementById("textarea-" + currentFileIndex); + ta.value = editor.getValue(); } - editor.session.setMode(aceMode); + }); - // set initial content from the textarea and add options - editor.session.setValue(textarea.value.trim()); - editor.setOptions({ - fontSize: "14px", - tabSize: 4, - useSoftTabs: true, - wrap: true, - showPrintMargin: false + // Initialize Select2 for the theme selector + $(document).ready(function () { + $('#theme-selector').select2({ + templateResult: formatOption, + placeholder: 'Select a theme', + allowClear: true + }).on('change', function () { + const selectedTheme = $('#theme-selector').val(); + localStorage.setItem('editortheme', selectedTheme); + editor.setTheme(selectedTheme); }); - - // update the hidden textarea whenever the Ace content changes - editor.session.on('change', () => { - textarea.value = editor.getValue(); - }); - - return editor; }); // Retrieve the list of available themes @@ -66,7 +86,7 @@ document.addEventListener("DOMContentLoaded", () => { themes = ThemeList.themes, themeSelector = document.querySelector('#theme-selector'); - themes.forEach(function (theme) { + themes.forEach((theme) => { var option = document.createElement('option'); option.value = theme.theme; option.textContent = theme.caption; @@ -81,7 +101,55 @@ document.addEventListener("DOMContentLoaded", () => { placeholder: 'Select a theme', allowClear: true }).on('change', function (_) { - localStorage.setItem('editortheme', themeSelector.value) - editors.forEach(editor => editor.setTheme(themeSelector.value)); + const selectedTheme = $('#theme-selector').val(); + localStorage.setItem('editortheme', selectedTheme); + editor.setTheme(selectedTheme); }); -}); \ No newline at end of file + + // Add event listener for Ctrl+S to save the current file + //TODO: save this + const editEl = document.querySelector('#editor-container'); + + document.addEventListener('keydown', (e) => { + if (e.ctrlKey && e.key === 's') { + e.preventDefault(); + if (currentFileIndex !== null) { + document.getElementById("textarea-" + currentFileIndex).value = editor.getValue(); + document.querySelector("form").submit(); + } + } + else if (editEl.contains(e.target)) { + document.getElementById("textarea-" + currentFileIndex).value = editor.getValue(); + } + }); + + // form submit intercept + document.querySelector("form").addEventListener("submit", async (e) => { + e.preventDefault(); + + const formData = new FormData(); + document.querySelectorAll("[id^='textarea-']").forEach((textarea) => { + if (textarea.dataset.edited === "true") { + formData.append(textarea.name, textarea.value); + delete textarea.dataset.edited; + } + }); + + await fetch(window.location.href, { + method: "POST", + headers: { + "X-Requested-With": "XMLHttpRequest" + }, + body: formData + }) + .then((response) => response.json()) + .then((data) => { + if (data.success) { + alert("Site updated successfully!"); // Or display a flash message + } + }) + .catch((error) => { + console.error("Error:", error); + }); + }); +}); diff --git a/static/styles.css b/static/styles.css index f9fcd73..94fdc91 100644 --- a/static/styles.css +++ b/static/styles.css @@ -261,6 +261,29 @@ button:hover, box-shadow: 0 5px 10px rgba(0, 0, 0, 0.15); } +.flash-messages { + margin: 10px 0; +} + +.flash-message { + padding: 10px; + margin-bottom: 10px; + border: 1px solid transparent; + border-radius: 4px; +} + +.flash-success { + color: #3c763d; + background-color: #dff0d8; + border-color: #d6e9c6; +} + +.flash-error { + color: #a94442; + background-color: #f2dede; + border-color: #ebccd1; +} + /* Theme Toggle */ #theme-toggle { background-color: var(--card-bg); @@ -295,6 +318,23 @@ button:hover, background-color: #0056b3; } +#editor-container { + width: 100%; + height: 400px; + border: 1px solid #ddd; + margin-top: 20px; + transition: transform 0.3s ease-in-out, opacity 0.3s ease-in-out; + transform: translateY(-100%); + opacity: 0; + display: none; /* Initially hidden */ +} + +#editor-container.visible { + transform: translateY(0); + opacity: 1; + display: block; /* Display when visible */ +} + @media (max-width: 768px) { .file-grid { grid-template-columns: repeat(2, 1fr); diff --git a/templates/dashboard.html b/templates/dashboard.html index c598a06..8c504d4 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -12,10 +12,18 @@ + +
+ + +
+
@@ -61,8 +69,7 @@
Edit - View Site + View Site
diff --git a/templates/edit.html b/templates/edit.html index 6cd4739..46ef788 100644 --- a/templates/edit.html +++ b/templates/edit.html @@ -4,32 +4,42 @@ {% block content %}

Edit {{ site.name }}

-View - Site +View Site
-
-
- - -
+
+ + +
- {% for filename, content in files.items() %}
- - -
- {% endfor %} + +
    + {% for filename, content in files.items() %} +
  • +
    + {{ filename }} +
  • + {% endfor %} +
+
- + + {% for filename, content in files.items() %} + + {% endfor %} + + +
+ + @@ -41,19 +51,11 @@ - {% endblock %} \ No newline at end of file