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