Compare commits

...

8 commits
v0.0.1 ... main

Author SHA1 Message Date
Timothy Rogers
bdcc6c531b Addressing feature request for Content-Disposition
All checks were successful
Build and Publish Docker Image / build (push) Successful in 41s
2025-05-26 14:38:13 -04:00
Timothy Rogers
75e301b0b2 Fixing regression with storage on disk
All checks were successful
Build and Publish Docker Image / build (push) Successful in 43s
2025-05-25 07:39:15 -04:00
Timothy Rogers
b8e4cd68fa fixing docker-ignore 2025-05-25 07:38:58 -04:00
Timothy Rogers
1cc2747629 fixed issues with pathing return local disk 2025-05-25 07:38:39 -04:00
Timothy Rogers
07909c41fc use html embed instead of md
All checks were successful
Build and Publish Docker Image / build (push) Successful in 54s
2025-05-24 20:48:49 -04:00
Timothy Rogers
92f4b5ad69 using full web links
All checks were successful
Build and Publish Docker Image / build (push) Successful in 52s
2025-05-24 20:46:32 -04:00
Timothy Rogers
666d9b27e8 Adding screenshots
All checks were successful
Build and Publish Docker Image / build (push) Successful in 1m2s
2025-05-24 20:43:35 -04:00
Timothy Rogers
4f5c5fd09d Added spinner and fixed asset delete bug
All checks were successful
Build and Publish Docker Image / build (push) Successful in 39s
2025-05-24 19:01:29 -04:00
13 changed files with 476 additions and 123 deletions

View file

@ -21,3 +21,5 @@ coverage.xml
venv/ venv/
ENV/ ENV/
instance/ instance/
repo-images/
.forgejo/

View file

@ -1,3 +1,6 @@
## Debugging
LOGGING_LEVEL=DEBUG
## Database Configuration ## Database Configuration
DATABASE_URL=sqlite:///instance/app.db DATABASE_URL=sqlite:///instance/app.db

View file

@ -10,6 +10,26 @@ A digital asset management system built with Flask and S3-compatible storage.
- License key management - License key management
- Docker container support - Docker container support
## Screenshots
<details>
<summary>Home Page</summary>
<p>
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/list-of-assets.webp" />
</p>
</details>
<details>
<summary>Asset View</summary>
<p>
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/asset-view.webp" />
</p>
</details>
<details>
<summary>Edit Page</summary>
<p>
<img src="https://git.hack13.dev/hack13/Personal-Digital-Asset-Manager/raw/branch/main/repo-images/edit-view.webp" />
</p>
</details>
## Container Registry ## Container Registry
This project includes automated container builds using Forgejo CI/CD. The container images are published to the project's container registry. This project includes automated container builds using Forgejo CI/CD. The container images are published to the project's container registry.

261
app.py
View file

@ -1,6 +1,7 @@
import os import os
import uuid import uuid
from flask import Flask, render_template, request, redirect, url_for, flash import mimetypes
from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, send_file, Response, stream_with_context
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from config import Config from config import Config
from flask_migrate import Migrate from flask_migrate import Migrate
@ -20,11 +21,13 @@ def create_app():
# Initialize extensions # Initialize extensions
db.init_app(app) db.init_app(app)
migrate.init_app(app, db) migrate.init_app(app, db)
# Initialize storage backend
app.storage = StorageBackend(app.config['STORAGE_URL'])
return app return app
app = create_app() app = create_app()
storage = StorageBackend(app.config['STORAGE_URL'])
def generate_unique_filename(original_filename): def generate_unique_filename(original_filename):
# Get the file extension # Get the file extension
@ -47,13 +50,22 @@ def index():
@app.route('/asset/add', methods=['GET', 'POST']) @app.route('/asset/add', methods=['GET', 'POST'])
def add_asset(): def add_asset():
if request.method == 'POST': if request.method == 'POST':
title = request.form.get('title') try:
description = request.form.get('description') title = request.form.get('title')
license_key = request.form.get('license_key') description = request.form.get('description')
featured_image = request.files.get('featured_image') license_key = request.form.get('license_key')
additional_files = request.files.getlist('additional_files') featured_image = request.files.get('featured_image')
additional_files = request.files.getlist('additional_files')
if not title:
return jsonify({'success': False, 'error': 'Title is required'})
if not featured_image:
return jsonify({'success': False, 'error': 'Featured image is required'})
if not allowed_file(featured_image.filename, is_featured_image=True):
return jsonify({'success': False, 'error': 'Invalid featured image format'})
if title and featured_image and allowed_file(featured_image.filename, is_featured_image=True):
# Process and convert featured image to WebP # Process and convert featured image to WebP
processed_image, ext = ImageProcessor.process_featured_image(featured_image) processed_image, ext = ImageProcessor.process_featured_image(featured_image)
@ -69,7 +81,7 @@ def add_asset():
) )
# Save featured image with unique filename using storage backend # Save featured image with unique filename using storage backend
storage.save(processed_file, unique_featured_filename) app.storage.save(processed_file, unique_featured_filename)
# Create asset with unique filename # Create asset with unique filename
asset = Asset( asset = Asset(
@ -87,7 +99,7 @@ def add_asset():
if file and allowed_file(file.filename): if file and allowed_file(file.filename):
original_filename = secure_filename(file.filename) original_filename = secure_filename(file.filename)
unique_filename = generate_unique_filename(original_filename) unique_filename = generate_unique_filename(original_filename)
storage.save(file, unique_filename) app.storage.save(file, unique_filename)
asset_file = AssetFile( asset_file = AssetFile(
filename=unique_filename, filename=unique_filename,
original_filename=original_filename, original_filename=original_filename,
@ -96,8 +108,19 @@ def add_asset():
db.session.add(asset_file) db.session.add(asset_file)
db.session.commit() db.session.commit()
flash('Asset added successfully!', 'success') return jsonify({
return redirect(url_for('index')) 'success': True,
'message': 'Asset added successfully!',
'redirect': url_for('index')
})
except Exception as e:
db.session.rollback()
app.logger.error(f"Error adding asset: {str(e)}", exc_info=True)
return jsonify({
'success': False,
'error': str(e)
})
return render_template('add_asset.html') return render_template('add_asset.html')
@ -111,90 +134,180 @@ def edit_asset(id):
asset = Asset.query.get_or_404(id) asset = Asset.query.get_or_404(id)
if request.method == 'POST': if request.method == 'POST':
asset.title = request.form.get('title') try:
asset.set_description(request.form.get('description')) asset.title = request.form.get('title')
license_key = request.form.get('license_key') if not asset.title:
asset.license_key = license_key.strip() if license_key else None return jsonify({'success': False, 'error': 'Title is required'})
# Handle featured image update asset.set_description(request.form.get('description'))
featured_image = request.files.get('featured_image') license_key = request.form.get('license_key')
if featured_image and featured_image.filename and allowed_file(featured_image.filename, is_featured_image=True): asset.license_key = license_key.strip() if license_key else None
# Delete old featured image
if asset.featured_image:
storage.delete(asset.featured_image)
# Process and convert featured image to WebP # Handle featured image update
processed_image, ext = ImageProcessor.process_featured_image(featured_image) featured_image = request.files.get('featured_image')
if featured_image and featured_image.filename:
# Generate unique filename if not allowed_file(featured_image.filename, is_featured_image=True):
original_featured_filename = secure_filename(featured_image.filename) return jsonify({'success': False, 'error': 'Invalid featured image format'})
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
# Create a FileStorage object from the processed image # Delete old featured image
processed_file = FileStorage( if asset.featured_image:
stream=processed_image, app.storage.delete(asset.featured_image)
filename=unique_featured_filename,
content_type='image/webp'
)
# Save the processed image # Process and convert featured image to WebP
storage.save(processed_file, unique_featured_filename) processed_image, ext = ImageProcessor.process_featured_image(featured_image)
asset.featured_image = unique_featured_filename
asset.original_featured_image = original_featured_filename # Generate unique filename
original_featured_filename = secure_filename(featured_image.filename)
unique_featured_filename = f"{uuid.uuid4().hex}{ext}"
# Handle additional files # Create a FileStorage object from the processed image
additional_files = request.files.getlist('additional_files') processed_file = FileStorage(
for file in additional_files: stream=processed_image,
if file and allowed_file(file.filename): filename=unique_featured_filename,
original_filename = secure_filename(file.filename) content_type='image/webp'
unique_filename = generate_unique_filename(original_filename)
storage.save(file, unique_filename)
asset_file = AssetFile(
filename=unique_filename,
original_filename=original_filename,
asset_id=asset.id
) )
db.session.add(asset_file)
db.session.commit() # Save the processed image
flash('Asset updated successfully!', 'success') app.storage.save(processed_file, unique_featured_filename)
return redirect(url_for('asset_detail', id=asset.id)) asset.featured_image = unique_featured_filename
asset.original_featured_image = original_featured_filename
# Handle additional files
additional_files = request.files.getlist('additional_files')
for file in additional_files:
if file and allowed_file(file.filename):
original_filename = secure_filename(file.filename)
unique_filename = generate_unique_filename(original_filename)
app.storage.save(file, unique_filename)
asset_file = AssetFile(
filename=unique_filename,
original_filename=original_filename,
asset_id=asset.id
)
db.session.add(asset_file)
db.session.commit()
return jsonify({
'success': True,
'message': 'Asset updated successfully!',
'redirect': url_for('asset_detail', id=asset.id)
})
except Exception as e:
db.session.rollback()
return jsonify({
'success': False,
'error': str(e)
})
return render_template('edit_asset.html', asset=asset) return render_template('edit_asset.html', asset=asset)
@app.route('/asset/<int:id>/delete', methods=['POST']) @app.route('/asset/<int:id>/delete', methods=['POST'])
def delete_asset(id): def delete_asset(id):
asset = Asset.query.get_or_404(id) try:
asset = Asset.query.get_or_404(id)
deletion_errors = []
# Delete featured image # Delete featured image
if asset.featured_image: if asset.featured_image:
storage.delete(asset.featured_image) if not app.storage.delete(asset.featured_image):
deletion_errors.append(f"Failed to delete featured image: {asset.featured_image}")
# Delete additional files # Delete additional files
for file in asset.files: for file in asset.files:
storage.delete(file.filename) if not app.storage.delete(file.filename):
db.session.delete(file) deletion_errors.append(f"Failed to delete file: {file.filename}")
db.session.delete(file)
db.session.delete(asset) db.session.delete(asset)
db.session.commit() db.session.commit()
flash('Asset deleted successfully!', 'success') if deletion_errors:
return redirect(url_for('index')) app.logger.error("Asset deletion had errors: %s", deletion_errors)
flash('Asset deleted from database, but some files could not be deleted: ' + '; '.join(deletion_errors), 'warning')
else:
flash('Asset deleted successfully!', 'success')
return redirect(url_for('index'))
except Exception as e:
db.session.rollback()
app.logger.error("Failed to delete asset: %s", str(e))
flash('Failed to delete asset: ' + str(e), 'error')
return redirect(url_for('asset_detail', id=id))
@app.route('/asset/file/<int:id>/delete', methods=['POST']) @app.route('/asset/file/<int:id>/delete', methods=['POST'])
def delete_asset_file(id): def delete_asset_file(id):
asset_file = AssetFile.query.get_or_404(id) try:
asset_id = asset_file.asset_id asset_file = AssetFile.query.get_or_404(id)
asset_id = asset_file.asset_id
filename = asset_file.filename
display_name = asset_file.original_filename or asset_file.filename
# Delete the file using storage backend # Delete the file using storage backend
storage.delete(asset_file.filename) if not app.storage.delete(filename):
error_msg = f'Failed to delete file {display_name} from storage'
app.logger.error(error_msg)
flash(error_msg, 'error')
return redirect(url_for('asset_detail', id=asset_id))
# Remove from database # Only remove from database if storage deletion was successful
db.session.delete(asset_file) db.session.delete(asset_file)
db.session.commit() db.session.commit()
flash('File deleted successfully!', 'success') flash('File deleted successfully!', 'success')
return redirect(url_for('asset_detail', id=asset_id)) return redirect(url_for('asset_detail', id=asset_id))
except Exception as e:
db.session.rollback()
app.logger.error("Failed to delete asset file: %s", str(e))
flash('Failed to delete file: ' + str(e), 'error')
return redirect(url_for('asset_detail', id=asset_id))
@app.route('/download/<int:file_id>')
def download_file(file_id):
"""Download a file with its original filename"""
try:
asset_file = AssetFile.query.get_or_404(file_id)
filename = asset_file.filename
download_name = asset_file.original_filename or filename
# Guess the mime type
mime_type, _ = mimetypes.guess_type(download_name)
if mime_type is None:
mime_type = 'application/octet-stream'
app.logger.debug(f"Starting download of {filename} as {download_name} with type {mime_type}")
try:
file_stream = app.storage.get_file_stream(filename)
def generate():
try:
while True:
chunk = file_stream.read(8192) # Read in 8KB chunks
if not chunk:
break
yield chunk
finally:
file_stream.close()
response = Response(
stream_with_context(generate()),
mimetype=mime_type
)
response.headers['Content-Disposition'] = f'attachment; filename="{download_name}"'
return response
except Exception as e:
app.logger.error(f"Error streaming file {filename}: {str(e)}", exc_info=True)
flash('Error downloading file. Please try again.', 'error')
return redirect(url_for('asset_detail', id=asset_file.asset_id))
except Exception as e:
app.logger.error(f"Error in download_file: {str(e)}", exc_info=True)
flash('File not found or error occurred.', 'error')
return redirect(url_for('index'))
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5432, debug=True) app.run(host='0.0.0.0', port=5432, debug=True)

View file

@ -22,6 +22,9 @@ class Config:
S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL') S3_ENDPOINT_URL = os.environ.get('S3_ENDPOINT_URL')
S3_PUBLIC_URL = os.environ.get('S3_PUBLIC_URL') S3_PUBLIC_URL = os.environ.get('S3_PUBLIC_URL')
# Logging configuration
LOGGING_LEVEL = os.environ.get('LOGGING_LEVEL', 'DEBUG' if os.environ.get('FLASK_ENV') != 'production' else 'INFO')
@staticmethod @staticmethod
def init_app(app): def init_app(app):
# Create necessary directories # Create necessary directories
@ -31,3 +34,7 @@ class Config:
if app.config['STORAGE_URL'].startswith('file://'): if app.config['STORAGE_URL'].startswith('file://'):
storage_path = app.config['STORAGE_URL'].replace('file://', '') storage_path = app.config['STORAGE_URL'].replace('file://', '')
os.makedirs(storage_path, exist_ok=True) os.makedirs(storage_path, exist_ok=True)
# Configure logging
import logging
logging.basicConfig(level=getattr(logging, app.config['LOGGING_LEVEL']))

BIN
repo-images/asset-view.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

BIN
repo-images/edit-view.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

View file

@ -1,9 +1,10 @@
import os import os
import fsspec import fsspec
import logging
import asyncio import asyncio
from typing import BinaryIO, Optional, Union from typing import BinaryIO, Optional, Union
from urllib.parse import urlparse from urllib.parse import urlparse
from flask import current_app from flask import current_app, url_for
from werkzeug.datastructures import FileStorage from werkzeug.datastructures import FileStorage
class StorageBackend: class StorageBackend:
@ -18,6 +19,15 @@ class StorageBackend:
self.parsed_url = urlparse(storage_url) self.parsed_url = urlparse(storage_url)
self.protocol = self.parsed_url.scheme or 'file' self.protocol = self.parsed_url.scheme or 'file'
# Set up logging - use Flask logger if in app context, otherwise use Python logging
try:
current_app.name # Check if we're in app context
self.logger = current_app.logger
except RuntimeError:
self.logger = logging.getLogger(__name__)
self.logger.info(f"Initializing StorageBackend with URL: {storage_url}, protocol: {self.protocol}")
# Configure filesystem # Configure filesystem
if self.protocol == 's3': if self.protocol == 's3':
self.fs = fsspec.filesystem( self.fs = fsspec.filesystem(
@ -31,28 +41,70 @@ class StorageBackend:
) )
self.bucket = self.parsed_url.netloc self.bucket = self.parsed_url.netloc
self.base_path = self.parsed_url.path.lstrip('/') self.base_path = self.parsed_url.path.lstrip('/')
self.logger.debug(f"Configured S3 storage with bucket: {self.bucket}, base_path: {self.base_path}")
else: else:
self.fs = fsspec.filesystem('file') self.fs = fsspec.filesystem('file')
self.base_path = self.parsed_url.path or '/uploads' self.base_path = self.parsed_url.path or '/uploads'
self.logger.debug(f"Configured local storage with base_path: {self.base_path}")
def _get_full_path(self, filename: str) -> str: def _get_full_path(self, filename: str) -> str:
"""Get full path for a file""" """Get full path for a file"""
if self.protocol == 's3': if self.protocol == 's3':
return os.path.join(self.base_path, filename) full_path = os.path.join(self.base_path, filename)
return os.path.join(current_app.root_path, self.base_path, filename) self.logger.debug(f"Generated S3 full path: {full_path}")
return full_path
full_path = os.path.join(current_app.root_path, self.base_path, filename)
self.logger.debug(f"Generated local full path: {full_path}")
return full_path
def save(self, file_storage: FileStorage, filename: str) -> str: def save(self, file_storage: FileStorage, filename: str) -> str:
"""Save a file to storage""" """Save a file to storage"""
full_path = self._get_full_path(filename) try:
full_path = self._get_full_path(filename)
if self.protocol == 's3': self.logger.info(f"Attempting to save file {filename} to {full_path}")
with self.fs.open(f"{self.bucket}/{full_path}", 'wb') as f:
file_storage.save(f) if not isinstance(file_storage, FileStorage):
return f"s3://{self.bucket}/{full_path}" self.logger.error(f"Invalid file_storage object type: {type(file_storage)}")
else: raise ValueError("file_storage must be a FileStorage object")
os.makedirs(os.path.dirname(full_path), exist_ok=True)
file_storage.save(full_path) if self.protocol == 's3':
return f"file://{full_path}" s3_path = f"{self.bucket}/{full_path}"
self.logger.debug(f"Opening S3 file for writing: {s3_path}")
with self.fs.open(s3_path, 'wb') as f:
self.logger.debug("Saving file content to S3...")
file_storage.save(f)
# Verify the file was saved
if self.fs.exists(s3_path):
self.logger.info(f"Successfully saved file to S3: {s3_path}")
else:
self.logger.error(f"Failed to verify file existence in S3: {s3_path}")
raise RuntimeError(f"Failed to verify file existence in S3: {s3_path}")
return f"s3://{self.bucket}/{full_path}"
else:
# Create directory structure if it doesn't exist
dir_path = os.path.dirname(full_path)
self.logger.debug(f"Creating local directory structure: {dir_path}")
os.makedirs(dir_path, exist_ok=True)
self.logger.debug(f"Saving file to local path: {full_path}")
file_storage.save(full_path)
# Verify the file was saved
if os.path.exists(full_path):
self.logger.info(f"Successfully saved file locally: {full_path}")
self.logger.debug(f"File size: {os.path.getsize(full_path)} bytes")
else:
self.logger.error(f"Failed to verify file existence locally: {full_path}")
raise RuntimeError(f"Failed to verify file existence locally: {full_path}")
return f"file://{full_path}"
except Exception as e:
self.logger.error(f"Error saving file {filename}: {str(e)}", exc_info=True)
raise
def open(self, filename: str, mode: str = 'rb') -> BinaryIO: def open(self, filename: str, mode: str = 'rb') -> BinaryIO:
"""Open a file from storage""" """Open a file from storage"""
@ -61,13 +113,43 @@ class StorageBackend:
return self.fs.open(f"{self.bucket}/{full_path}", mode) return self.fs.open(f"{self.bucket}/{full_path}", mode)
return self.fs.open(full_path, mode) return self.fs.open(full_path, mode)
def delete(self, filename: str) -> None: def delete(self, filename: str) -> bool:
"""Delete a file from storage""" """
full_path = self._get_full_path(filename) Delete a file from storage
if self.protocol == 's3': Returns True if file was deleted or didn't exist, False if deletion failed
self.fs.delete(f"{self.bucket}/{full_path}") """
else: try:
self.fs.delete(full_path) full_path = self._get_full_path(filename)
if self.protocol == 's3':
path = f"{self.bucket}/{full_path}"
self.logger.debug(f"Attempting to delete S3 file: {path}")
if self.fs.exists(path):
self.logger.debug(f"File exists, deleting: {path}")
self.fs.delete(path)
deleted = not self.fs.exists(path)
if deleted:
self.logger.debug(f"Successfully deleted file: {path}")
else:
self.logger.error(f"Failed to delete file: {path}")
return deleted
self.logger.debug(f"File doesn't exist, skipping delete: {path}")
return True # File didn't exist
else:
self.logger.debug(f"Attempting to delete local file: {full_path}")
if self.fs.exists(full_path):
self.logger.debug(f"File exists, deleting: {full_path}")
self.fs.delete(full_path)
deleted = not os.path.exists(full_path)
if deleted:
self.logger.debug(f"Successfully deleted file: {full_path}")
else:
self.logger.error(f"Failed to delete file: {full_path}")
return deleted
self.logger.debug(f"File doesn't exist, skipping delete: {full_path}")
return True # File didn't exist
except Exception as e:
self.logger.error(f"Failed to delete file {filename}: {str(e)}", exc_info=True)
return False
def url_for(self, filename: str) -> str: def url_for(self, filename: str) -> str:
"""Get URL for a file""" """Get URL for a file"""
@ -82,11 +164,27 @@ class StorageBackend:
return f"{endpoint}/{self.bucket}/{full_path}" return f"{endpoint}/{self.bucket}/{full_path}"
return f"s3://{self.bucket}/{full_path}" return f"s3://{self.bucket}/{full_path}"
else: else:
return f"/uploads/{filename}" # For local storage, use static/uploads path
return url_for('static', filename=f'uploads/{filename}')
def exists(self, filename: str) -> bool: def exists(self, filename: str) -> bool:
"""Check if a file exists""" """Check if a file exists"""
full_path = self._get_full_path(filename) full_path = self._get_full_path(filename)
if self.protocol == 's3': if self.protocol == 's3':
return self.fs.exists(f"{self.bucket}/{full_path}") return self.fs.exists(f"{self.bucket}/{full_path}")
return self.fs.exists(full_path) return self.fs.exists(full_path)
def get_file_stream(self, filename: str):
"""Get a file stream from storage"""
try:
if self.protocol == 's3':
s3_path = f"{self.bucket}/{self._get_full_path(filename)}"
self.logger.debug(f"Opening S3 file stream: {s3_path}")
return self.fs.open(s3_path, 'rb')
else:
full_path = self._get_full_path(filename)
self.logger.debug(f"Opening local file stream: {full_path}")
return open(full_path, 'rb')
except Exception as e:
self.logger.error(f"Failed to get file stream for {filename}: {str(e)}", exc_info=True)
raise

View file

@ -4,7 +4,7 @@
</div> </div>
<div class="form-container"> <div class="form-container">
<form method="POST" enctype="multipart/form-data" class="form"> <form method="POST" enctype="multipart/form-data" class="form" id="assetForm">
<div class="form-group"> <div class="form-group">
<label for="title" class="form-label">Title</label> <label for="title" class="form-label">Title</label>
<input <input
@ -82,7 +82,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="button button-primary"> <button type="submit" class="button button-primary" id="submitBtn">
<i class="fas fa-save"></i> Save Asset <i class="fas fa-save"></i> Save Asset
</button> </button>
<a href="{{ url_for('index') }}" class="button button-secondary"> <a href="{{ url_for('index') }}" class="button button-secondary">
@ -115,8 +115,11 @@
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg", "https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
}); });
// File input preview handling
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("assetForm");
const loadingOverlay = document.querySelector(".loading-overlay");
const loadingText = document.querySelector(".loading-text");
// Featured image preview // Featured image preview
const featuredInput = document.getElementById("featured_image"); const featuredInput = document.getElementById("featured_image");
const featuredPreview = document.querySelector(".file-input-preview"); const featuredPreview = document.querySelector(".file-input-preview");
@ -152,6 +155,45 @@
`; `;
}); });
}); });
form.addEventListener("submit", function (e) {
e.preventDefault();
const formData = new FormData(form);
loadingOverlay.style.display = "flex";
loadingText.textContent = "Processing...";
const xhr = new XMLHttpRequest();
xhr.open("POST", "{{ url_for('add_asset') }}", true);
xhr.onload = function() {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
if (response.success) {
window.location.href = response.redirect;
} else {
loadingText.textContent = "Failed: " + response.error;
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
}
} else {
loadingText.textContent = "Upload failed! Please try again.";
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
}
};
xhr.onerror = function() {
loadingText.textContent = "Network error! Please try again.";
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
};
xhr.send(formData);
});
}); });
</script> </script>
{% endblock %} {% endblock %}

View file

@ -64,33 +64,19 @@
</div> </div>
{% endif %} {% endif %}
<div class="attached-files content-box"> <div class="files-section">
<h2>Attached Files</h2> <h2>Files</h2>
{% if asset.files %} {% if asset.files %}
<ul class="files-list"> <ul class="files-list">
{% for file in asset.files %} {% for file in asset.files %}
<li class="file-item"> <li class="file-item">
<a <a
href="{{ file.file_url }}" href="{{ url_for('download_file', file_id=file.id) }}"
target="_blank"
class="file-link" class="file-link"
> >
<i class="fas fa-file"></i> <i class="fas fa-file"></i>
{{ file.original_filename or file.filename }} {{ file.original_filename or file.filename }}
</a> </a>
<form
method="POST"
action="{{ url_for('delete_asset_file', id=file.id) }}"
class="inline-form"
>
<button
type="submit"
class="button button-small button-danger"
onclick="return confirm('Are you sure you want to delete this file?')"
>
<i class="fas fa-times"></i>
</button>
</form>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View file

@ -27,11 +27,47 @@
crossorigin="anonymous" crossorigin="anonymous"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
/> />
<style>
.loading-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 9999;
justify-content: center;
align-items: center;
flex-direction: column;
}
.loading-spinner {
width: 50px;
height: 50px;
border: 5px solid #f3f3f3;
border-top: 5px solid #3498db;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
.loading-text {
color: white;
font-size: 18px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/trumbowyg.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/trumbowyg.min.js"></script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
<div class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text">Processing...</div>
</div>
<nav class="main-nav"> <nav class="main-nav">
<div class="nav-container"> <div class="nav-container">
<div class="nav-brand">Digital Assets Manager</div> <div class="nav-brand">Digital Assets Manager</div>
@ -68,6 +104,5 @@
}); });
}); });
</script> </script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
</body> </body>
</html> </html>

View file

@ -4,7 +4,7 @@
</div> </div>
<div class="form-container"> <div class="form-container">
<form method="POST" enctype="multipart/form-data" class="form"> <form method="POST" enctype="multipart/form-data" class="form" id="editAssetForm">
<div class="form-group"> <div class="form-group">
<label for="title" class="form-label">Title</label> <label for="title" class="form-label">Title</label>
<input <input
@ -28,7 +28,7 @@
<label class="form-label">Current Featured Image</label> <label class="form-label">Current Featured Image</label>
<div class="current-image"> <div class="current-image">
<img <img
src="{{ url_for('static', filename='uploads/' + asset.featured_image) }}" src="{{ asset.featured_image_url }}"
alt="{{ asset.title }}" alt="{{ asset.title }}"
/> />
</div> </div>
@ -90,7 +90,7 @@
</div> </div>
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="button button-primary"> <button type="submit" class="button button-primary" id="submitBtn">
<i class="fas fa-save"></i> Update Asset <i class="fas fa-save"></i> Update Asset
</button> </button>
<a <a
@ -109,8 +109,7 @@
{% for file in asset.files %} {% for file in asset.files %}
<li class="file-item"> <li class="file-item">
<a <a
href="{{ url_for('static', filename='uploads/' + file.filename) }}" href="{{ url_for('download_file', file_id=file.id) }}"
target="_blank"
class="file-link" class="file-link"
> >
<i class="fas fa-file"></i> <i class="fas fa-file"></i>
@ -159,8 +158,11 @@
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg", "https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
}); });
// File input preview handling (keeping your existing code)
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
const form = document.getElementById("editAssetForm");
const loadingOverlay = document.querySelector(".loading-overlay");
const loadingText = document.querySelector(".loading-text");
// Featured image preview // Featured image preview
const featuredInput = document.getElementById("featured_image"); const featuredInput = document.getElementById("featured_image");
const featuredPreview = document.querySelector(".file-input-preview"); const featuredPreview = document.querySelector(".file-input-preview");
@ -196,6 +198,51 @@
`; `;
}); });
}); });
// Handle form submission
form.addEventListener("submit", function (e) {
e.preventDefault();
const formData = new FormData(form);
loadingOverlay.style.display = "flex";
loadingText.textContent = "Processing...";
const xhr = new XMLHttpRequest();
xhr.open("POST", "{{ url_for('edit_asset', id=asset.id) }}", true);
xhr.onload = function() {
if (xhr.status === 200) {
try {
const response = JSON.parse(xhr.responseText);
if (response.success) {
window.location.href = response.redirect;
} else {
loadingText.textContent = "Failed: " + response.error;
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
}
} catch (e) {
// Handle non-JSON response (redirect)
window.location.href = "{{ url_for('asset_detail', id=asset.id) }}";
}
} else {
loadingText.textContent = "Update failed! Please try again.";
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
}
};
xhr.onerror = function() {
loadingText.textContent = "Network error! Please try again.";
setTimeout(() => {
loadingOverlay.style.display = "none";
}, 2000);
};
xhr.send(formData);
});
}); });
</script> </script>
{% endblock %} {% endblock %}