add code
This commit is contained in:
parent
f1da01193b
commit
b0a4567169
20 changed files with 1734 additions and 0 deletions
23
.dockerignore
Normal file
23
.dockerignore
Normal 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
40
Dockerfile
Normal 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
183
app.py
Normal 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
15
config.py
Normal 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
13
entrypoint.sh
Normal 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
5
extensions.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from flask_sqlalchemy import SQLAlchemy
|
||||||
|
from flask_migrate import Migrate
|
||||||
|
|
||||||
|
db = SQLAlchemy()
|
||||||
|
migrate = Migrate()
|
1
migrations/README
Normal file
1
migrations/README
Normal file
|
@ -0,0 +1 @@
|
||||||
|
Single-database configuration for Flask.
|
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
23
migrations/versions/add_original_feature_image.py
Normal file
23
migrations/versions/add_original_feature_image.py
Normal 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')
|
32
migrations/versions/bea92ecef03b_add_license_key_column.py
Normal file
32
migrations/versions/bea92ecef03b_add_license_key_column.py
Normal 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
56
models.py
Normal 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
7
requirements.txt
Normal 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
549
static/css/style.css
Normal 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
157
templates/add_asset.html
Normal 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
130
templates/asset_detail.html
Normal 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
73
templates/base.html
Normal 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">×</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
201
templates/edit_asset.html
Normal 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
39
templates/index.html
Normal 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 %}
|
Loading…
Add table
Add a link
Reference in a new issue