This commit is contained in:
Timothy Rogers 2024-11-10 16:01:57 -05:00
parent f1da01193b
commit b0a4567169
20 changed files with 1734 additions and 0 deletions

23
.dockerignore Normal file
View file

@ -0,0 +1,23 @@
__pycache__
*.pyc
*.pyo
*.pyd
.Python
env
pip-log.txt
pip-delete-this-directory.txt
.tox
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.log
.git
.gitignore
.env
.venv
venv/
ENV/
instance/

40
Dockerfile Normal file
View file

@ -0,0 +1,40 @@
# Use Python 3.9 slim image
FROM python:3.9-slim
# Set working directory
WORKDIR /app
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1
ENV FLASK_APP=app.py
ENV FLASK_ENV=production
# Install system dependencies
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Create uploads directory
RUN mkdir -p static/uploads
# Make entrypoint script executable
RUN chmod +x entrypoint.sh
# Create volume for uploads
VOLUME /app/static/uploads
# Expose port
EXPOSE 5000
# Use the entrypoint script
ENTRYPOINT ["./entrypoint.sh"]

183
app.py Normal file
View file

@ -0,0 +1,183 @@
import os
import uuid
from flask import Flask, render_template, request, redirect, url_for, flash
from werkzeug.utils import secure_filename
from config import Config
from flask_migrate import Migrate
from extensions import db, migrate
from models import Asset, AssetFile
def create_app():
app = Flask(__name__)
app.config.from_object(Config)
# Ensure the instance folder exists
os.makedirs(app.instance_path, exist_ok=True)
# Ensure the uploads folder exists
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
# Initialize extensions
db.init_app(app)
migrate.init_app(app, db)
# Initialize database
#with app.app_context():
# db.create_all()
return app
app = create_app()
def generate_unique_filename(original_filename):
"""Generate a unique filename while preserving the original extension"""
# Get the file extension
ext = os.path.splitext(original_filename)[1] if '.' in original_filename else ''
# Generate a unique filename using UUID
return f"{uuid.uuid4().hex}{ext}"
def allowed_file(filename):
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'pdf', 'zip', 'spp', 'unitypackage', 'fbx', 'blend', 'webp'}
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def delete_file(filename):
if filename:
file_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
if os.path.exists(file_path):
os.remove(file_path)
@app.route('/')
def index():
assets = Asset.query.order_by(Asset.created_at.desc()).all()
return render_template('index.html', assets=assets)
@app.route('/asset/add', methods=['GET', 'POST'])
def add_asset():
if request.method == 'POST':
title = request.form.get('title')
description = request.form.get('description')
license_key = request.form.get('license_key')
featured_image = request.files.get('featured_image')
additional_files = request.files.getlist('additional_files')
if title and featured_image and allowed_file(featured_image.filename):
# Generate unique filename for featured image
original_featured_filename = secure_filename(featured_image.filename)
unique_featured_filename = generate_unique_filename(original_featured_filename)
# Save featured image with unique filename
featured_image.save(os.path.join(app.config['UPLOAD_FOLDER'], unique_featured_filename))
# Create asset with unique filename
asset = Asset(
title=title,
featured_image=unique_featured_filename,
original_featured_image=original_featured_filename,
license_key=license_key.strip() if license_key else None
)
asset.set_description(description)
db.session.add(asset)
db.session.commit()
# Save additional files with unique filenames
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)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], unique_filename))
asset_file = AssetFile(
filename=unique_filename,
original_filename=original_filename,
asset_id=asset.id
)
db.session.add(asset_file)
db.session.commit()
flash('Asset added successfully!', 'success')
return redirect(url_for('index'))
return render_template('add_asset.html')
@app.route('/asset/<int:id>')
def asset_detail(id):
asset = Asset.query.get_or_404(id)
return render_template('asset_detail.html', asset=asset)
@app.route('/asset/<int:id>/edit', methods=['GET', 'POST'])
def edit_asset(id):
asset = Asset.query.get_or_404(id)
if request.method == 'POST':
asset.title = request.form.get('title')
asset.set_description(request.form.get('description'))
license_key = request.form.get('license_key')
asset.license_key = license_key.strip() if license_key else None
# Handle featured image update
featured_image = request.files.get('featured_image')
if featured_image and featured_image.filename and allowed_file(featured_image.filename):
# Delete old featured image
delete_file(asset.featured_image)
# Save new featured image
original_featured_filename = secure_filename(featured_image.filename)
unique_featured_filename = generate_unique_filename(original_featured_filename)
# Save featured image with unique filename
featured_image.save(os.path.join(app.config['UPLOAD_FOLDER'], unique_featured_filename))
asset.featured_image = unique_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)
file.save(os.path.join(app.config['UPLOAD_FOLDER'], unique_filename))
asset_file = AssetFile(
filename=unique_filename,
original_filename=original_filename,
asset_id=asset.id
)
db.session.add(asset_file)
db.session.commit()
flash('Asset updated successfully!', 'success')
return redirect(url_for('asset_detail', id=asset.id))
return render_template('edit_asset.html', asset=asset)
@app.route('/asset/<int:id>/delete', methods=['POST'])
def delete_asset(id):
asset = Asset.query.get_or_404(id)
# Delete featured image
delete_file(asset.featured_image)
# Delete additional files
for file in asset.files:
delete_file(file.filename)
db.session.delete(file)
db.session.delete(asset)
db.session.commit()
flash('Asset deleted successfully!', 'success')
return redirect(url_for('index'))
@app.route('/asset/file/<int:id>/delete', methods=['POST'])
def delete_asset_file(id):
asset_file = AssetFile.query.get_or_404(id)
asset_id = asset_file.asset_id
# Delete the file
delete_file(asset_file.filename)
# Remove from database
db.session.delete(asset_file)
db.session.commit()
flash('File deleted successfully!', 'success')
return redirect(url_for('asset_detail', id=asset_id))
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)

15
config.py Normal file
View file

@ -0,0 +1,15 @@
import os
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
class Config:
SECRET_KEY = os.environ.get('SECRET_KEY', 'your-secret-key')
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL', 'sqlite:///' + os.path.join(BASE_DIR, 'instance', 'app.db'))
SQLALCHEMY_TRACK_MODIFICATIONS = False
UPLOAD_FOLDER = os.path.join(BASE_DIR, 'static', 'uploads')
@staticmethod
def init_app(app):
# Create necessary directories
os.makedirs(os.path.dirname(app.config['SQLALCHEMY_DATABASE_URI'].replace('sqlite:///', '')), exist_ok=True)
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)

13
entrypoint.sh Normal file
View file

@ -0,0 +1,13 @@
#!/bin/bash
# Wait for database to be ready (if using PostgreSQL)
# until PGPASSWORD=$DATABASE_PASSWORD psql -h "$DATABASE_HOST" -U "$DATABASE_USER" -d "$DATABASE_NAME" -c '\q'; do
# echo "Waiting for database..."
# sleep 1
# done
# Apply database migrations
flask db upgrade
# Start gunicorn
exec gunicorn --bind 0.0.0.0:5000 app:app

5
extensions.py Normal file
View file

@ -0,0 +1,5 @@
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
db = SQLAlchemy()
migrate = Migrate()

1
migrations/README Normal file
View file

@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View file

@ -0,0 +1,50 @@
# A generic, single database configuration.
[alembic]
# template used to generate migration files
# file_template = %%(rev)s_%%(slug)s
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View file

@ -0,0 +1,113 @@
import logging
from logging.config import fileConfig
from flask import current_app
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
fileConfig(config.config_file_name)
logger = logging.getLogger('alembic.env')
def get_engine():
try:
# this works with Flask-SQLAlchemy<3 and Alchemical
return current_app.extensions['migrate'].db.get_engine()
except (TypeError, AttributeError):
# this works with Flask-SQLAlchemy>=3
return current_app.extensions['migrate'].db.engine
def get_engine_url():
try:
return get_engine().url.render_as_string(hide_password=False).replace(
'%', '%%')
except AttributeError:
return str(get_engine().url).replace('%', '%%')
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
config.set_main_option('sqlalchemy.url', get_engine_url())
target_db = current_app.extensions['migrate'].db
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_metadata():
if hasattr(target_db, 'metadatas'):
return target_db.metadatas[None]
return target_db.metadata
def run_migrations_offline():
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url, target_metadata=get_metadata(), literal_binds=True
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
# this callback is used to prevent an auto-migration from being generated
# when there are no changes to the schema
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
def process_revision_directives(context, revision, directives):
if getattr(config.cmd_opts, 'autogenerate', False):
script = directives[0]
if script.upgrade_ops.is_empty():
directives[:] = []
logger.info('No changes in schema detected.')
conf_args = current_app.extensions['migrate'].configure_args
if conf_args.get("process_revision_directives") is None:
conf_args["process_revision_directives"] = process_revision_directives
connectable = get_engine()
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=get_metadata(),
**conf_args
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

24
migrations/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,23 @@
"""Add original_featured_image column
Revision ID: 1234567890ab
Revises:
Create Date: 2023-XX-XX
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1234567890ab'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# Add the new column
op.add_column('asset', sa.Column('original_featured_image', sa.String(200)))
def downgrade():
# Remove the column
op.drop_column('asset', 'original_featured_image')

View file

@ -0,0 +1,32 @@
"""Add license-key column
Revision ID: bea92ecef03b
Revises: 1234567890ab
Create Date: 2024-10-31 12:16:06.022171
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bea92ecef03b'
down_revision = '1234567890ab'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('asset', schema=None) as batch_op:
batch_op.add_column(sa.Column('license_key', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('asset', schema=None) as batch_op:
batch_op.drop_column('license_key')
# ### end Alembic commands ###

56
models.py Normal file
View file

@ -0,0 +1,56 @@
from datetime import datetime
from extensions import db
import bleach
ALLOWED_TAGS = [
'a', 'abbr', 'acronym', 'b', 'blockquote', 'code', 'em', 'i', 'li', 'ol',
'strong', 'ul', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span',
'br', 'table', 'tr', 'td', 'th', 'thead', 'tbody', 'img'
]
ALLOWED_ATTRIBUTES = {
'*': ['class'],
'a': ['href', 'title'],
'img': ['src', 'alt', 'width', 'height'],
}
class Asset(db.Model):
id = db.Column(db.Integer, primary_key=True)
title = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
featured_image = db.Column(db.String(200))
original_featured_image = db.Column(db.String(200))
license_key = db.Column(db.String(255))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
files = db.relationship('AssetFile', backref='asset', lazy=True)
def set_description(self, description):
"""Sanitize HTML content before saving"""
if description:
clean_html = bleach.clean(
description,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True
)
self.description = clean_html
else:
self.description = None
@property
def safe_description(self):
"""Return sanitized HTML content"""
if self.description:
return bleach.clean(
self.description,
tags=ALLOWED_TAGS,
attributes=ALLOWED_ATTRIBUTES,
strip=True
)
return ''
class AssetFile(db.Model):
id = db.Column(db.Integer, primary_key=True)
filename = db.Column(db.String(200), nullable=False)
original_filename = db.Column(db.String(200))
asset_id = db.Column(db.Integer, db.ForeignKey('asset.id'), nullable=False)

7
requirements.txt Normal file
View file

@ -0,0 +1,7 @@
Flask
Flask-SQLAlchemy
flask-migrate>=4.0.4
Flask-WTF
Pillow
bleach
gunicorn

549
static/css/style.css Normal file
View file

@ -0,0 +1,549 @@
/* Base Styles */
:root {
--primary-color: #4f46e5;
--primary-dark: #4338ca;
--danger-color: #dc2626;
--success-color: #059669;
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-300: #d1d5db;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", sans-serif;
line-height: 1.5;
color: var(--gray-800);
background-color: var(--gray-50);
}
/* Navigation */
.main-nav {
background-color: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 1rem 0;
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand {
font-size: 1.25rem;
font-weight: 600;
color: var(--gray-800);
}
.nav-links {
display: flex;
gap: 1rem;
}
.nav-link {
text-decoration: none;
color: var(--gray-600);
padding: 0.5rem 1rem;
border-radius: 0.375rem;
transition: all 0.2s;
}
.nav-link:hover {
background-color: var(--gray-100);
color: var(--gray-800);
}
/* Main Content */
.main-content {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1rem;
}
/* Page Header */
.page-header {
margin-bottom: 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.page-header h1 {
font-size: 1.875rem;
font-weight: 600;
color: var(--gray-800);
}
/* Gallery Grid */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1.5rem;
}
/* Asset Cards */
.asset-card {
background: white;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition:
transform 0.2s,
box-shadow 0.2s;
}
.asset-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.asset-card-image {
aspect-ratio: 1/1; /* Changed from 16/9 to 1/1 for square images */
overflow: hidden;
position: relative; /* Added for better image control */
}
.asset-card-image img {
width: 100%;
height: 100%;
object-fit: contain; /* Changed from cover to contain */
background-color: #f5f5f5; /* Optional: adds a light background */
padding: 0.5rem; /* Optional: adds some padding around the image */
}
.asset-card-content {
padding: 1rem;
}
.asset-card-content h3 {
margin-bottom: 1rem;
font-size: 1.125rem;
font-weight: 600;
}
/* Buttons */
.button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
border: none;
cursor: pointer;
transition: all 0.2s;
}
.button-primary {
background-color: var(--primary-color);
color: white;
}
.button-primary:hover {
background-color: var(--primary-dark);
}
.button-secondary {
background-color: var(--gray-100);
color: var(--gray-700);
}
.button-secondary:hover {
background-color: var(--gray-200);
}
.button-danger {
background-color: var(--danger-color);
color: white;
}
.button-danger:hover {
background-color: #b91c1c;
}
.button-small {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Forms */
.form-container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.form-container h2 {
margin-bottom: 1.5rem;
}
/* Content Box */
.content-box {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 1.5rem;
}
/* Files List */
.files-list {
list-style: none;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 1px solid var(--gray-200);
}
.file-item:last-child {
border-bottom: none;
}
.file-link {
color: var(--gray-700);
text-decoration: none;
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-link:hover {
color: var(--primary-color);
}
/* Improved Alert Messages */
.alert {
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateY(-100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.alert-success {
background-color: #ecfdf5;
color: var(--success-color);
}
.alert-danger {
background-color: #fef2f2;
color: var(--danger-color);
}
.alert-close {
background: none;
border: none;
cursor: pointer;
font-size: 1.25rem;
opacity: 0.5;
}
.alert-close:hover {
opacity: 1;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.empty-state i {
font-size: 3rem;
color: var(--gray-400);
margin-bottom: 1rem;
}
.empty-state h2 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.empty-state p {
color: var(--gray-600);
margin-bottom: 1.5rem;
}
/* Asset Detail Page */
.asset-content {
display: grid;
grid-template-columns: 2fr 3fr;
gap: 2rem;
}
.asset-main-image {
background: white;
padding: 1rem;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.asset-main-image img {
width: 100%;
height: auto;
border-radius: 0.25rem;
}
/* Utility Classes */
.inline-form {
display: inline;
}
.text-muted {
color: var(--gray-600);
}
/* Form Styles */
.form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-label {
font-weight: 500;
color: var(--gray-700);
}
.form-input {
padding: 0.625rem;
border: 1px solid var(--gray-300);
border-radius: 0.375rem;
font-size: 0.875rem;
transition: border-color 0.2s;
}
.form-input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
/* File Input Styles */
.file-input-wrapper {
position: relative;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.file-input-label {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1rem;
background-color: var(--gray-100);
color: var(--gray-700);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.file-input-label:hover {
background-color: var(--gray-200);
}
.file-input-label i {
font-size: 1.25rem;
}
/* File Preview Styles */
.preview-image {
margin-top: 1rem;
padding: 0.5rem;
background: var(--gray-100);
border-radius: 0.375rem;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
}
.preview-image img {
max-width: 200px;
max-height: 200px;
border-radius: 0.25rem;
}
.preview-image .filename {
font-size: 0.75rem;
color: var(--gray-600);
}
.selected-file {
margin-top: 0.5rem;
padding: 0.5rem;
background: var(--gray-100);
border-radius: 0.375rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--gray-700);
}
/* Current Image Display */
.current-image {
max-width: 300px;
padding: 0.5rem;
background: var(--gray-100);
border-radius: 0.375rem;
}
.current-image img {
width: 100%;
height: auto;
border-radius: 0.25rem;
}
/* Form Actions */
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid var(--gray-200);
}
/* Current Files Section */
.current-files {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--gray-200);
}
.current-files h3 {
margin-bottom: 1rem;
font-size: 1.125rem;
color: var(--gray-700);
}
/* Responsive Navigation */
@media (max-width: 640px) {
.nav-container {
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.nav-links {
width: 100%;
justify-content: center;
}
}
/* Responsive Gallery */
@media (max-width: 480px) {
.gallery {
grid-template-columns: 1fr; /* Single column on very small screens */
}
.main-content {
padding: 1rem;
}
.page-header {
flex-direction: column;
gap: 1rem;
text-align: center;
}
}
/* Editor Styles */
.trumbowyg-box {
margin: 0 !important; /* Override default margins */
width: 100%;
border: 1px solid var(--gray-300) !important;
border-radius: 0.375rem !important;
}
.trumbowyg-editor {
padding: 1rem !important;
min-height: 300px !important;
background: white !important;
font-family: Inter, sans-serif !important;
font-size: 0.875rem !important;
color: var(--gray-800) !important;
}
.license-key-container {
margin-top: 1rem;
}
.license-key-wrapper {
display: flex;
gap: 0.5rem;
align-items: center;
}
.license-key-input {
flex: 1;
font-family: "Courier New", monospace;
background-color: var(--gray-50);
padding: 0.75rem;
cursor: default;
}
.license-copy-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
white-space: nowrap;
}
.license-copy-btn i {
font-size: 1rem;
transition: all 0.2s ease;
}
/* Optional: Add a subtle animation when copying */
.license-copy-btn i.fa-check {
color: var(--success-color);
transform: scale(1.1);
}

157
templates/add_asset.html Normal file
View file

@ -0,0 +1,157 @@
{% extends "base.html" %} {% block content %}
<div class="page-header">
<h1>Add New Digital Asset</h1>
</div>
<div class="form-container">
<form method="POST" enctype="multipart/form-data" class="form">
<div class="form-group">
<label for="title" class="form-label">Title</label>
<input
type="text"
id="title"
name="title"
class="form-input"
required
/>
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea
id="description"
name="description"
class="form-input"
></textarea>
</div>
<div class="form-group">
<label for="license_key">License Key</label>
<input
type="text"
class="form-control"
id="license_key"
name="license_key"
placeholder="Enter the asset's license key"
/>
<small class="form-text text-muted"
>Enter the license key that came with your purchased
asset</small
>
</div>
<div class="form-group">
<label for="featured_image" class="form-label"
>Featured Image</label
>
<div class="file-input-wrapper">
<input
type="file"
id="featured_image"
name="featured_image"
class="file-input"
required
accept="image/*"
/>
<label for="featured_image" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose a file...</span>
</label>
<div class="file-input-preview"></div>
</div>
</div>
<div class="form-group">
<label for="additional_files" class="form-label"
>Additional Files</label
>
<div class="file-input-wrapper">
<input
type="file"
id="additional_files"
name="additional_files"
class="file-input"
multiple
/>
<label for="additional_files" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose files...</span>
</label>
<div class="selected-files"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="button button-primary">
<i class="fas fa-save"></i> Save Asset
</button>
<a href="{{ url_for('index') }}" class="button button-secondary">
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
</div>
{% endblock %} {% block scripts %}
<script>
// Initialize Trumbowyg
$("#description").trumbowyg({
btns: [
["viewHTML"],
["undo", "redo"],
["formatting"],
["strong", "em", "del"],
["link"],
["insertImage"],
["justifyLeft", "justifyCenter", "justifyRight"],
["unorderedList", "orderedList"],
["horizontalRule"],
["removeformat"],
["fullscreen"],
],
autogrow: true,
resetCss: true,
removeformatPasted: true,
svgPath:
"https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/icons.svg",
});
// File input preview handling
document.addEventListener("DOMContentLoaded", function () {
// Featured image preview
const featuredInput = document.getElementById("featured_image");
const featuredPreview = document.querySelector(".file-input-preview");
featuredInput.addEventListener("change", function () {
const file = this.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
featuredPreview.innerHTML = `
<div class="preview-image">
<img src="${e.target.result}" alt="Preview">
<span class="filename">${file.name}</span>
</div>
`;
};
reader.readAsDataURL(file);
}
});
// Additional files list
const additionalInput = document.getElementById("additional_files");
const selectedFiles = document.querySelector(".selected-files");
additionalInput.addEventListener("change", function () {
selectedFiles.innerHTML = "";
Array.from(this.files).forEach((file) => {
selectedFiles.innerHTML += `
<div class="selected-file">
<i class="fas fa-file"></i>
<span>${file.name}</span>
</div>
`;
});
});
});
</script>
{% endblock %}

130
templates/asset_detail.html Normal file
View file

@ -0,0 +1,130 @@
{% extends "base.html" %} {% block content %}
<div class="asset-detail">
<div class="page-header">
<h1>{{ asset.title }}</h1>
<div class="page-actions">
<a
href="{{ url_for('edit_asset', id=asset.id) }}"
class="button button-secondary"
>
<i class="fas fa-edit"></i> Edit
</a>
<form
method="POST"
action="{{ url_for('delete_asset', id=asset.id) }}"
class="inline-form"
>
<button
type="submit"
class="button button-danger"
onclick="return confirm('Are you sure you want to delete this asset?')"
>
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</div>
<div class="asset-content">
<div class="asset-main-image">
<img
src="{{ url_for('static', filename='uploads/' + asset.featured_image) }}"
alt="{{ asset.title }}"
/>
</div>
<div class="asset-info">
<div class="description content-box">
<h2>Description</h2>
{{ asset.safe_description|safe }}
</div>
{% if asset.license_key %}
<div class="content-box">
<h2>License Key</h2>
<div class="license-key-container">
<div class="license-key-wrapper">
<input
type="text"
class="form-input license-key-input"
value="{{ asset.license_key }}"
readonly
aria-label="License Key"
/>
<button
class="button button-secondary license-copy-btn"
type="button"
aria-label="Copy License Key"
>
<i class="fas fa-copy"></i>
<span>Copy</span>
</button>
</div>
</div>
</div>
{% endif %}
<div class="attached-files content-box">
<h2>Attached Files</h2>
{% if asset.files %}
<ul class="files-list">
{% for file in asset.files %}
<li class="file-item">
<a
href="{{ url_for('static', filename='uploads/' + file.filename) }}"
target="_blank"
class="file-link"
>
<i class="fas fa-file"></i>
{{ file.original_filename or file.filename }}
</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>
{% endfor %}
</ul>
{% else %}
<p class="text-muted">No files attached</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %} {% block scripts %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const copyButton = document.querySelector(".license-copy-btn");
if (copyButton) {
copyButton.addEventListener("click", async function () {
const input = this.previousElementSibling;
try {
await navigator.clipboard.writeText(input.value);
const buttonText = this.querySelector("span");
const buttonIcon = this.querySelector("i");
buttonText.textContent = "Copied!";
buttonIcon.classList.replace("fa-copy", "fa-check");
setTimeout(() => {
buttonText.textContent = "Copy";
buttonIcon.classList.replace("fa-check", "fa-copy");
}, 2000);
} catch (err) {
console.error("Failed to copy text: ", err);
}
});
}
});
</script>
{% endblock %}

73
templates/base.html Normal file
View file

@ -0,0 +1,73 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Digital Assets Manager - Organize and manage your digital assets"
/>
<title>Digital Assets Manager</title>
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/Trumbowyg/2.27.3/ui/trumbowyg.min.css"
integrity="sha512-Fm8kRNVGCBZn0sPmwJbVXlqfJmPC13zRsMElZenX6v721g/H7OukJd8XzDEBRQ2FSATK8xNF9UYvzsCtUpfeJg=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<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>
{% block head %}{% endblock %}
</head>
<body>
<nav class="main-nav">
<div class="nav-container">
<div class="nav-brand">Digital Assets Manager</div>
<div class="nav-links">
<a href="{{ url_for('index') }}" class="nav-link"
><i class="fas fa-home"></i> Home</a
>
<a href="{{ url_for('add_asset') }}" class="nav-link"
><i class="fas fa-plus"></i> Add Asset</a
>
</div>
</div>
</nav>
<main class="main-content">
{% with messages = get_flashed_messages(with_categories=true) %} {%
if messages %} {% for category, message in messages %}
<div class="alert alert-{{ category }}">
{{ message }}
<button class="alert-close">&times;</button>
</div>
{% endfor %} {% endif %} {% endwith %} {% block content %}{%
endblock %}
</main>
{% block scripts %}{% endblock %}
<script>
// Close alert messages
document.addEventListener("DOMContentLoaded", function () {
document.querySelectorAll(".alert-close").forEach((button) => {
button.addEventListener("click", () => {
button.parentElement.style.display = "none";
});
});
});
</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
</body>
</html>

201
templates/edit_asset.html Normal file
View file

@ -0,0 +1,201 @@
{% extends "base.html" %} {% block content %}
<div class="page-header">
<h1>Edit Digital Asset</h1>
</div>
<div class="form-container">
<form method="POST" enctype="multipart/form-data" class="form">
<div class="form-group">
<label for="title" class="form-label">Title</label>
<input
type="text"
id="title"
name="title"
class="form-input"
value="{{ asset.title }}"
required
/>
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea id="description" name="description" class="form-input">
{{ asset.description }}</textarea
>
</div>
<div class="form-group">
<label class="form-label">Current Featured Image</label>
<div class="current-image">
<img
src="{{ url_for('static', filename='uploads/' + asset.featured_image) }}"
alt="{{ asset.title }}"
/>
</div>
</div>
<div class="form-group">
<label for="license_key">License Key</label>
<input
type="text"
class="form-input"
id="license_key"
name="license_key"
value="{{ asset.license_key or '' }}"
placeholder="Enter the asset's license key"
/>
<small class="form-text text-muted"
>The license key that came with your purchased asset</small
>
</div>
<div class="form-group">
<label for="featured_image" class="form-label"
>Update Featured Image</label
>
<div class="file-input-wrapper">
<input
type="file"
id="featured_image"
name="featured_image"
class="file-input"
accept="image/*"
/>
<label for="featured_image" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose a new image...</span>
</label>
<div class="file-input-preview"></div>
</div>
</div>
<div class="form-group">
<label for="additional_files" class="form-label"
>Add More Files</label
>
<div class="file-input-wrapper">
<input
type="file"
id="additional_files"
name="additional_files"
class="file-input"
multiple
/>
<label for="additional_files" class="file-input-label">
<i class="fas fa-cloud-upload-alt"></i>
<span>Choose files...</span>
</label>
<div class="selected-files"></div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="button button-primary">
<i class="fas fa-save"></i> Update Asset
</button>
<a
href="{{ url_for('asset_detail', id=asset.id) }}"
class="button button-secondary"
>
<i class="fas fa-times"></i> Cancel
</a>
</div>
</form>
{% if asset.files %}
<div class="current-files">
<h3>Current Files</h3>
<ul class="files-list">
{% for file in asset.files %}
<li class="file-item">
<a
href="{{ url_for('static', filename='uploads/' + file.filename) }}"
target="_blank"
class="file-link"
>
<i class="fas fa-file"></i>
{{ file.original_filename or file.filename }}
</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>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endblock %} {% block scripts %}
<script>
// Initialize Trumbowyg
$("#description").trumbowyg({
btns: [
["viewHTML"],
["undo", "redo"],
["formatting"],
["strong", "em", "del"],
["link"],
["insertImage"],
["justifyLeft", "justifyCenter", "justifyRight"],
["unorderedList", "orderedList"],
["horizontalRule"],
["removeformat"],
["fullscreen"],
],
autogrow: true,
resetCss: true,
removeformatPasted: true,
svgPath:
"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 () {
// Featured image preview
const featuredInput = document.getElementById("featured_image");
const featuredPreview = document.querySelector(".file-input-preview");
featuredInput.addEventListener("change", function () {
const file = this.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function (e) {
featuredPreview.innerHTML = `
<div class="preview-image">
<img src="${e.target.result}" alt="Preview">
<span class="filename">${file.name}</span>
</div>
`;
};
reader.readAsDataURL(file);
}
});
// Additional files list
const additionalInput = document.getElementById("additional_files");
const selectedFiles = document.querySelector(".selected-files");
additionalInput.addEventListener("change", function () {
selectedFiles.innerHTML = "";
Array.from(this.files).forEach((file) => {
selectedFiles.innerHTML += `
<div class="selected-file">
<i class="fas fa-file"></i>
<span>${file.name}</span>
</div>
`;
});
});
});
</script>
{% endblock %}

39
templates/index.html Normal file
View file

@ -0,0 +1,39 @@
{% extends "base.html" %} {% block content %}
<div class="page-header">
<h1>My Digital Assets</h1>
</div>
<div class="gallery">
{% for asset in assets %}
<div class="asset-card">
<div class="asset-card-image">
<img
src="{{ url_for('static', filename='uploads/' + asset.featured_image) }}"
alt="{{ asset.title }}"
loading="lazy"
/>
</div>
<div class="asset-card-content">
<h3>{{ asset.title }}</h3>
<div class="asset-card-actions">
<a
href="{{ url_for('asset_detail', id=asset.id) }}"
class="button button-primary"
>
<i class="fas fa-eye"></i> View Details
</a>
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-box-open"></i>
<h2>No assets yet</h2>
<p>Start by adding your first digital asset!</p>
<a href="{{ url_for('add_asset') }}" class="button button-primary">
<i class="fas fa-plus"></i> Add Asset
</a>
</div>
{% endfor %}
</div>
{% endblock %}