This commit is contained in:
2025-05-25 10:31:22 +02:00
parent 1caeb8fc98
commit 225e33056a
102 changed files with 8390 additions and 0 deletions

26
Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first to leverage Docker cache
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application
COPY . .
# Set environment variables
ENV FLASK_APP=app.py
ENV FLASK_ENV=development
# Expose the port the app runs on
EXPOSE 5000
# Command to run the application
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:app"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

70
app.py Normal file
View File

@@ -0,0 +1,70 @@
from flask import Flask, send_from_directory
from flask_migrate import Migrate
from flask_login import LoginManager
from dotenv import load_dotenv
import os
from models import db, User
from flask_wtf.csrf import CSRFProtect, generate_csrf
from routes.room_files import room_files_bp
from routes.user import user_bp
from routes.room_members import room_members_bp
from routes.trash import trash_bp
from tasks import cleanup_trash
import click
from utils import timeago
# Load environment variables
load_dotenv()
def create_app():
app = Flask(__name__)
# Configure the database
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:1253@localhost:5432/docupulse'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['UPLOAD_FOLDER'] = os.path.join(app.root_path, 'static', 'uploads')
# Initialize extensions
db.init_app(app)
migrate = Migrate(app, db)
login_manager = LoginManager(app)
login_manager.login_view = 'auth.login'
csrf = CSRFProtect(app)
@app.context_processor
def inject_csrf_token():
return dict(csrf_token=generate_csrf())
# User loader for Flask-Login
@login_manager.user_loader
def load_user(user_id):
return User.query.get(int(user_id))
# Initialize routes
from routes import init_app
init_app(app)
app.register_blueprint(room_files_bp, url_prefix='/api/rooms')
app.register_blueprint(room_members_bp, url_prefix='/api/rooms')
app.register_blueprint(trash_bp, url_prefix='/api/rooms')
app.register_blueprint(user_bp)
@app.cli.command("cleanup-trash")
def cleanup_trash_command():
"""Clean up files that have been in trash for more than 30 days."""
cleanup_trash()
click.echo("Trash cleanup completed.")
# Register custom filters
app.jinja_env.filters['timeago'] = timeago
return app
app = create_app()
@app.route('/uploads/profile_pics/<filename>')
def profile_pic(filename):
return send_from_directory(os.path.join(os.getcwd(), 'uploads', 'profile_pics'), filename)
if __name__ == '__main__':
app.run(debug=True)

59
clear_files_and_db.py Normal file
View File

@@ -0,0 +1,59 @@
import os
import shutil
from app import create_app
from models import db, RoomFile, Room, RoomMemberPermission
from sqlalchemy import text
app = create_app()
def clear_all_data():
with app.app_context():
# Delete records in the correct order to handle foreign key constraints
# 1. Delete all RoomFile records from the database
RoomFile.query.delete()
print("All RoomFile records deleted.")
# 2. Delete all RoomMemberPermission records
RoomMemberPermission.query.delete()
print("All RoomMemberPermission records deleted.")
# 3. Delete all room_members associations
db.session.execute(text('DELETE FROM room_members'))
print("All room_members associations deleted.")
# 4. Delete all Room records
Room.query.delete()
print("All Room records deleted.")
# Commit the database changes
db.session.commit()
print("Database cleanup completed.")
def clear_filesystem():
# 1. Clear the data/rooms directory
data_root = os.path.join(os.path.dirname(__file__), 'data', 'rooms')
if os.path.exists(data_root):
for item in os.listdir(data_root):
item_path = os.path.join(data_root, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared data/rooms directory")
# 2. Clear the uploads directory except for profile_pics
uploads_dir = os.path.join(os.path.dirname(__file__), 'uploads')
if os.path.exists(uploads_dir):
for item in os.listdir(uploads_dir):
if item != 'profile_pics':
item_path = os.path.join(uploads_dir, item)
if os.path.isfile(item_path):
os.remove(item_path)
elif os.path.isdir(item_path):
shutil.rmtree(item_path)
print("Cleared uploads directory")
if __name__ == '__main__':
clear_all_data()
clear_filesystem()
print("Cleanup completed successfully!")

27
clear_specific_files.py Normal file
View File

@@ -0,0 +1,27 @@
from app import create_app, db
from app.models import RoomFile, Room
import os
app = create_app()
with app.app_context():
# Get the Test room
room = Room.query.filter_by(name='Test').first()
if not room:
print("Test room not found")
exit(1)
# Delete from database
files = ['Screenshot_2025-03-19_100338.png', 'Screenshot_2025-03-19_100419.png']
deleted = RoomFile.query.filter_by(room_id=room.id, name__in=files).delete()
db.session.commit()
print(f"Deleted {deleted} records from database")
# Delete from filesystem
room_path = os.path.join('data', 'rooms', str(room.id))
for file in files:
file_path = os.path.join(room_path, file)
if os.path.exists(file_path):
os.remove(file_path)
print(f"Deleted file: {file_path}")
else:
print(f"File not found: {file_path}")

39
docker-compose.yml Normal file
View File

@@ -0,0 +1,39 @@
version: '3.8'
services:
web:
build: .
ports:
- "10334:5000"
environment:
- DATABASE_URL=postgresql://postgres:postgres@db:5432/flask_app
- FLASK_APP=app.py
- FLASK_ENV=development
depends_on:
- db
volumes:
- .:/app
- room_data:/data
networks:
- app-network
db:
image: postgres:15
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=flask_app
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- app-network
volumes:
postgres_data:
room_data:
networks:
app-network:
driver: bridge

50
forms.py Normal file
View File

@@ -0,0 +1,50 @@
from flask_wtf import FlaskForm
from wtforms import StringField, TextAreaField, BooleanField, SubmitField, PasswordField
from wtforms.validators import DataRequired, Email, Length, Optional, ValidationError
from models import User
from flask_login import current_user
from flask_wtf.file import FileField, FileAllowed
class UserForm(FlaskForm):
first_name = StringField('First Name', validators=[DataRequired(), Length(min=2, max=100)])
last_name = StringField('Last Name', validators=[DataRequired(), Length(min=2, max=100)])
email = StringField('Email', validators=[DataRequired(), Email(), Length(max=150)])
phone = StringField('Phone (Optional)', validators=[Optional(), Length(max=20)])
company = StringField('Company (Optional)', validators=[Optional(), Length(max=100)])
position = StringField('Position (Optional)', validators=[Optional(), Length(max=100)])
notes = TextAreaField('Notes (Optional)', validators=[Optional()])
is_active = BooleanField('Active', default=True)
is_admin = BooleanField('Admin Role', default=False)
new_password = PasswordField('New Password (Optional)')
confirm_password = PasswordField('Confirm Password (Optional)')
profile_picture = FileField('Profile Picture (Optional)', validators=[FileAllowed(['jpg', 'jpeg', 'png', 'gif'], 'Images only!')])
submit = SubmitField('Save Contact')
def validate_is_admin(self, field):
# Only validate when trying to uncheck admin status
if not field.data:
# Get the user being edited (either current user or a new user)
user = User.query.filter_by(email=self.email.data).first()
# If this is an existing admin user and they're the only admin
if user and user.is_admin:
total_admins = User.query.filter_by(is_admin=True).count()
if total_admins <= 1:
raise ValidationError('There must be at least one admin user in the system.')
def validate(self, extra_validators=None):
rv = super().validate(extra_validators=extra_validators)
if not rv:
return False
if self.new_password.data or self.confirm_password.data:
if not self.new_password.data or not self.confirm_password.data:
self.confirm_password.errors.append('Both password fields are required.')
return False
if self.new_password.data != self.confirm_password.data:
self.confirm_password.errors.append('Passwords must match.')
return False
return True
class RoomForm(FlaskForm):
name = StringField('Room Name', validators=[DataRequired(), Length(min=3, max=100)])
description = TextAreaField('Description', validators=[Optional(), Length(max=500)])
submit = SubmitField('Create Room')

1
migrations/README Normal file
View File

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

Binary file not shown.

46
migrations/alembic.ini Normal file
View File

@@ -0,0 +1,46 @@
[alembic]
script_location = migrations
# A generic, single database configuration.
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic,flask_migrate
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[logger_flask_migrate]
level = INFO
handlers =
qualname = flask_migrate
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

113
migrations/env.py Normal file
View File

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

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

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

View File

@@ -0,0 +1,36 @@
"""Add user authentication fields
Revision ID: 1c297825e3a9
Revises:
Create Date: 2025-05-23 08:39:40.494853
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '1c297825e3a9'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('username', sa.String(length=150), nullable=False),
sa.Column('email', sa.String(length=150), nullable=False),
sa.Column('password_hash', sa.String(length=128), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('user')
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add is_admin to Contact model
Revision ID: 25da158dd705
Revises: 7a5747dc773f
Create Date: 2025-05-23 16:10:53.731035
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '25da158dd705'
down_revision = '7a5747dc773f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('contact', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('contact', schema=None) as batch_op:
batch_op.drop_column('is_admin')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""Add room member permissions table
Revision ID: 26b0e5357f52
Revises: 2c5f57dddb78
Create Date: 2025-05-23 21:44:58.832286
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '26b0e5357f52'
down_revision = '2c5f57dddb78'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('room_member_permissions',
sa.Column('room_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('can_view', sa.Boolean(), nullable=False),
sa.Column('can_upload', sa.Boolean(), nullable=False),
sa.Column('can_delete', sa.Boolean(), nullable=False),
sa.Column('can_share', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('room_id', 'user_id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('room_member_permissions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,40 @@
"""Add room members table
Revision ID: 2c5f57dddb78
Revises: 3a5b8d8e53cd
Create Date: 2025-05-23 21:27:17.497481
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2c5f57dddb78'
down_revision = '3a5b8d8e53cd'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('room_members',
sa.Column('room_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('room_id', 'user_id')
)
with op.batch_alter_table('room', schema=None) as batch_op:
batch_op.drop_column('is_private')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_private', sa.BOOLEAN(), autoincrement=False, nullable=True))
op.drop_table('room_members')
# ### end Alembic commands ###

View File

@@ -0,0 +1,37 @@
"""Add rooms table
Revision ID: 3a5b8d8e53cd
Revises: c21f243b3640
Create Date: 2025-05-23 21:25:27.880150
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '3a5b8d8e53cd'
down_revision = 'c21f243b3640'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('room',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('is_private', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['created_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('room')
# ### end Alembic commands ###

View File

@@ -0,0 +1,40 @@
"""Add contact fields to User model
Revision ID: 43dfd2543fad
Revises: dbcb5d2d3ed0
Create Date: 2025-05-23 09:24:23.926302
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '43dfd2543fad'
down_revision = 'dbcb5d2d3ed0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('phone', sa.String(length=20), nullable=True))
batch_op.add_column(sa.Column('company', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('position', sa.String(length=100), nullable=True))
batch_op.add_column(sa.Column('notes', sa.Text(), nullable=True))
batch_op.add_column(sa.Column('is_active', sa.Boolean(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('is_active')
batch_op.drop_column('notes')
batch_op.drop_column('position')
batch_op.drop_column('company')
batch_op.drop_column('phone')
# ### end Alembic commands ###

View File

@@ -0,0 +1,47 @@
"""add room_file table for file/folder metadata and uploader info
Revision ID: 64b5c28510b0
Revises: b978642e7b10
Create Date: 2025-05-24 10:07:02.159730
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '64b5c28510b0'
down_revision = 'b978642e7b10'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('room_file',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('room_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('path', sa.String(length=1024), nullable=False),
sa.Column('type', sa.String(length=10), nullable=False),
sa.Column('size', sa.BigInteger(), nullable=True),
sa.Column('modified', sa.Float(), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
batch_op.drop_column('preferred_view')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('preferred_view', sa.VARCHAR(length=10), autoincrement=False, nullable=False))
op.drop_table('room_file')
# ### end Alembic commands ###

View File

@@ -0,0 +1,60 @@
"""Add starred column to room_file table
Revision ID: 6651332488d9
Revises: be1f7bdd10e1
Create Date: 2025-05-24 18:14:38.320999
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '6651332488d9'
down_revision = 'be1f7bdd10e1'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.add_column(sa.Column('starred', sa.Boolean(), nullable=True))
batch_op.alter_column('path',
existing_type=sa.VARCHAR(length=1024),
type_=sa.String(length=255),
existing_nullable=False)
batch_op.alter_column('size',
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True)
batch_op.alter_column('uploaded_by',
existing_type=sa.INTEGER(),
nullable=True)
batch_op.alter_column('uploaded_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.alter_column('uploaded_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
batch_op.alter_column('uploaded_by',
existing_type=sa.INTEGER(),
nullable=False)
batch_op.alter_column('size',
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True)
batch_op.alter_column('path',
existing_type=sa.String(length=255),
type_=sa.VARCHAR(length=1024),
existing_nullable=False)
batch_op.drop_column('starred')
# ### end Alembic commands ###

View File

@@ -0,0 +1,34 @@
"""fix existing users
Revision ID: 7554ab70efe7
Revises: 43dfd2543fad
Create Date: 2024-03-19 10:05:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
# revision identifiers, used by Alembic.
revision = '7554ab70efe7'
down_revision = '43dfd2543fad'
branch_labels = None
depends_on = None
def upgrade():
# Update existing users to set default values
connection = op.get_bind()
connection.execute(
text("""
UPDATE "user"
SET position = 'Administrator',
is_active = true
WHERE position IS NULL
""")
)
def downgrade():
pass

View File

@@ -0,0 +1,24 @@
"""merge heads
Revision ID: 76da0573e84b
Revises: add_deleted_by_to_room_file, create_user_starred_file_table
Create Date: 2025-05-25 10:03:03.423064
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '76da0573e84b'
down_revision = ('add_deleted_by_to_room_file', 'create_user_starred_file_table')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@@ -0,0 +1,42 @@
"""Update last_name field in User model
Revision ID: 7a5747dc773f
Revises: c243d6a1843d
Create Date: 2024-03-19 10:15:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.sql import text
# revision identifiers, used by Alembic.
revision = '7a5747dc773f'
down_revision = 'c243d6a1843d'
branch_labels = None
depends_on = None
def upgrade():
# First, update any null last_name values to '(You)'
connection = op.get_bind()
connection.execute(
text("""
UPDATE "user"
SET last_name = '(You)'
WHERE last_name IS NULL
""")
)
# Then make the column non-nullable
op.alter_column('user', 'last_name',
existing_type=sa.String(length=150),
nullable=False,
server_default='(You)')
def downgrade():
op.alter_column('user', 'last_name',
existing_type=sa.String(length=150),
nullable=True,
server_default=None)

View File

@@ -0,0 +1,33 @@
"""Add deleted_by column to room_file table
Revision ID: add_deleted_by_to_room_file
Revises: add_deleted_column_to_room_file
Create Date: 2024-03-19 10:45:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_deleted_by_to_room_file'
down_revision = 'add_deleted_column_to_room_file'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.add_column(sa.Column('deleted_by', sa.Integer(), nullable=True))
batch_op.add_column(sa.Column('deleted_at', sa.DateTime(), nullable=True))
batch_op.create_foreign_key('fk_room_file_deleted_by_user', 'user', ['deleted_by'], ['id'])
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.drop_constraint('fk_room_file_deleted_by_user', type_='foreignkey')
batch_op.drop_column('deleted_at')
batch_op.drop_column('deleted_by')
# ### end Alembic commands ###

View File

@@ -0,0 +1,29 @@
"""Add deleted column to room_file table
Revision ID: add_deleted_column_to_room_file
Revises: add_trashed_file_table
Create Date: 2024-03-19 10:30:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'add_deleted_column_to_room_file'
down_revision = 'add_trashed_file_table'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.add_column(sa.Column('deleted', sa.Boolean(), nullable=False, server_default='false'))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.drop_column('deleted')
# ### end Alembic commands ###

View File

@@ -0,0 +1,42 @@
"""Add trashed file table
Revision ID: add_trashed_file_table
Revises: 6651332488d9
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'add_trashed_file_table'
down_revision = '6651332488d9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('trashed_file',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('room_id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('original_path', sa.String(length=255), nullable=False),
sa.Column('type', sa.String(length=10), nullable=False),
sa.Column('size', sa.Integer(), nullable=True),
sa.Column('modified', sa.Float(), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=True),
sa.Column('uploaded_at', sa.DateTime(), nullable=True),
sa.Column('deleted_by', sa.Integer(), nullable=False),
sa.Column('deleted_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['deleted_by'], ['user.id'], ),
sa.ForeignKeyConstraint(['room_id'], ['room.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['user.id'], ),
sa.PrimaryKeyConstraint('id')
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('trashed_file')
# ### end Alembic commands ###

View File

@@ -0,0 +1,38 @@
"""add preferred_view to user
Revision ID: b978642e7b10
Revises: 26b0e5357f52
Create Date: 2025-05-24 08:36:03.426879
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'b978642e7b10'
down_revision = '26b0e5357f52'
branch_labels = None
depends_on = None
def upgrade():
# Add preferred_view as nullable first
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('preferred_view', sa.String(length=10), nullable=True))
# Set default value for existing users
op.execute("UPDATE \"user\" SET preferred_view = 'grid'")
# Make the column non-nullable
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('preferred_view', nullable=False)
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('preferred_view')
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('preferred_view', sa.VARCHAR(length=10), autoincrement=False, nullable=False))
# ### end Alembic commands ###

View File

@@ -0,0 +1,36 @@
"""Add granular permissions to RoomMemberPermission
Revision ID: be1f7bdd10e1
Revises: 64b5c28510b0
Create Date: 2025-05-24 12:32:19.239241
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'be1f7bdd10e1'
down_revision = '64b5c28510b0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
batch_op.add_column(sa.Column('can_download', sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column('can_rename', sa.Boolean(), nullable=False, server_default=sa.false()))
batch_op.add_column(sa.Column('can_move', sa.Boolean(), nullable=False, server_default=sa.false()))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('room_member_permissions', schema=None) as batch_op:
batch_op.drop_column('can_move')
batch_op.drop_column('can_rename')
batch_op.drop_column('can_download')
# ### end Alembic commands ###

View File

@@ -0,0 +1,51 @@
"""add profile_picture to user
Revision ID: c21f243b3640
Revises: 25da158dd705
Create Date: 2025-05-23 19:28:16.977187
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'c21f243b3640'
down_revision = '25da158dd705'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('contact')
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('profile_picture', sa.String(length=255), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('profile_picture')
op.create_table('contact',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('first_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('last_name', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
sa.Column('email', sa.VARCHAR(length=150), autoincrement=False, nullable=False),
sa.Column('phone', sa.VARCHAR(length=20), autoincrement=False, nullable=True),
sa.Column('company', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('position', sa.VARCHAR(length=100), autoincrement=False, nullable=True),
sa.Column('notes', sa.TEXT(), autoincrement=False, nullable=True),
sa.Column('created_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('updated_at', postgresql.TIMESTAMP(), autoincrement=False, nullable=True),
sa.Column('owner_id', sa.INTEGER(), autoincrement=False, nullable=False),
sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.Column('is_admin', sa.BOOLEAN(), autoincrement=False, nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], name=op.f('contact_owner_id_fkey')),
sa.PrimaryKeyConstraint('id', name=op.f('contact_pkey')),
sa.UniqueConstraint('email', name=op.f('contact_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,32 @@
"""Add last_name to User model
Revision ID: c243d6a1843d
Revises: 7554ab70efe7
Create Date: 2025-05-23 16:00:09.905001
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'c243d6a1843d'
down_revision = '7554ab70efe7'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('last_name', sa.String(length=150), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('last_name')
# ### end Alembic commands ###

View File

@@ -0,0 +1,41 @@
"""Create user_starred_file table and remove starred column
Revision ID: create_user_starred_file_table
Revises: 6651332488d9
Create Date: 2024-03-19 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = 'create_user_starred_file_table'
down_revision = '6651332488d9'
branch_labels = None
depends_on = None
def upgrade():
# Create user_starred_file table
op.create_table('user_starred_file',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('file_id', sa.Integer(), nullable=False),
sa.Column('starred_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['file_id'], ['room_file.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'file_id', name='unique_user_file_star')
)
# Remove starred column from room_file
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.drop_column('starred')
def downgrade():
# Add starred column back to room_file
with op.batch_alter_table('room_file', schema=None) as batch_op:
batch_op.add_column(sa.Column('starred', sa.Boolean(), nullable=True))
# Drop user_starred_file table
op.drop_table('user_starred_file')

View File

@@ -0,0 +1,38 @@
"""Increase password_hash column length
Revision ID: d8dcbf9fe881
Revises: 1c297825e3a9
Create Date: 2025-05-23 08:45:00.693155
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd8dcbf9fe881'
down_revision = '1c297825e3a9'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.VARCHAR(length=128),
type_=sa.String(length=256),
existing_nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.alter_column('password_hash',
existing_type=sa.String(length=256),
type_=sa.VARCHAR(length=128),
existing_nullable=True)
# ### end Alembic commands ###

View File

@@ -0,0 +1,52 @@
"""Add Contact model
Revision ID: dbcb5d2d3ed0
Revises: d8dcbf9fe881
Create Date: 2025-05-23 08:55:10.537722
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'dbcb5d2d3ed0'
down_revision = 'd8dcbf9fe881'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('contact',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('first_name', sa.String(length=100), nullable=False),
sa.Column('last_name', sa.String(length=100), nullable=False),
sa.Column('email', sa.String(length=150), nullable=False),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('company', sa.String(length=100), nullable=True),
sa.Column('position', sa.String(length=100), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('owner_id', sa.Integer(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.ForeignKeyConstraint(['owner_id'], ['user.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email')
)
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.add_column(sa.Column('is_admin', sa.Boolean(), nullable=True))
batch_op.add_column(sa.Column('created_at', sa.DateTime(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('user', schema=None) as batch_op:
batch_op.drop_column('created_at')
batch_op.drop_column('is_admin')
op.drop_table('contact')
# ### end Alembic commands ###

127
models.py Normal file
View File

@@ -0,0 +1,127 @@
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from datetime import datetime
from sqlalchemy.orm import relationship
db = SQLAlchemy()
# Association table for room members
room_members = db.Table('room_members',
db.Column('room_id', db.Integer, db.ForeignKey('room.id'), primary_key=True),
db.Column('user_id', db.Integer, db.ForeignKey('user.id'), primary_key=True)
)
class User(UserMixin, db.Model):
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(150), unique=True, nullable=False)
last_name = db.Column(db.String(150), nullable=False, default='(You)')
email = db.Column(db.String(150), unique=True, nullable=False)
password_hash = db.Column(db.String(256))
is_admin = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
phone = db.Column(db.String(20))
company = db.Column(db.String(100))
position = db.Column(db.String(100))
notes = db.Column(db.Text)
is_active = db.Column(db.Boolean, default=True)
profile_picture = db.Column(db.String(255))
preferred_view = db.Column(db.String(10), default='grid', nullable=False) # 'grid' or 'list'
room_permissions = relationship('RoomMemberPermission', back_populates='user')
def set_password(self, password):
self.password_hash = generate_password_hash(password)
def check_password(self, password):
return check_password_hash(self.password_hash, password)
def __repr__(self):
return f'<User {self.username}>'
class Room(db.Model):
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
description = db.Column(db.Text)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
# Relationships
creator = db.relationship('User', backref='created_rooms', foreign_keys=[created_by])
members = db.relationship('User', secondary=room_members, backref=db.backref('rooms', lazy='dynamic'))
member_permissions = relationship('RoomMemberPermission', back_populates='room', cascade='all, delete-orphan')
def __repr__(self):
return f'<Room {self.name}>'
# Association table for room members with permissions
class RoomMemberPermission(db.Model):
__tablename__ = 'room_member_permissions'
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), primary_key=True)
can_view = db.Column(db.Boolean, default=True, nullable=False)
can_download = db.Column(db.Boolean, default=False, nullable=False)
can_upload = db.Column(db.Boolean, default=False, nullable=False)
can_delete = db.Column(db.Boolean, default=False, nullable=False)
can_rename = db.Column(db.Boolean, default=False, nullable=False)
can_move = db.Column(db.Boolean, default=False, nullable=False)
can_share = db.Column(db.Boolean, default=False, nullable=False)
# Relationships
user = relationship('User', back_populates='room_permissions')
room = relationship('Room', back_populates='member_permissions')
class RoomFile(db.Model):
__tablename__ = 'room_file'
id = db.Column(db.Integer, primary_key=True)
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), nullable=False)
name = db.Column(db.String(255), nullable=False)
path = db.Column(db.String(255), nullable=False, default='')
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
size = db.Column(db.Integer) # in bytes, null for folders
modified = db.Column(db.Float) # timestamp
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
deleted = db.Column(db.Boolean, default=False) # New field for deleted status
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id')) # New field for tracking who deleted the file
deleted_at = db.Column(db.DateTime) # New field for tracking when the file was deleted
uploader = db.relationship('User', backref='uploaded_files', foreign_keys=[uploaded_by])
deleter = db.relationship('User', backref='deleted_room_files', foreign_keys=[deleted_by])
room = db.relationship('Room', backref='files')
starred_by = db.relationship('User', secondary='user_starred_file', backref='starred_files')
def __repr__(self):
return f'<RoomFile {self.name} ({self.type}) in {self.path}>'
class UserStarredFile(db.Model):
__tablename__ = 'user_starred_file'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
file_id = db.Column(db.Integer, db.ForeignKey('room_file.id'), nullable=False)
starred_at = db.Column(db.DateTime, default=datetime.utcnow)
# Add unique constraint to prevent duplicate stars
__table_args__ = (
db.UniqueConstraint('user_id', 'file_id', name='unique_user_file_star'),
)
def __repr__(self):
return f'<UserStarredFile user_id={self.user_id} file_id={self.file_id}>'
class TrashedFile(db.Model):
__tablename__ = 'trashed_file'
id = db.Column(db.Integer, primary_key=True)
room_id = db.Column(db.Integer, db.ForeignKey('room.id'), nullable=False)
name = db.Column(db.String(255), nullable=False)
original_path = db.Column(db.String(255), nullable=False, default='')
type = db.Column(db.String(10), nullable=False) # 'file' or 'folder'
size = db.Column(db.Integer) # in bytes, null for folders
modified = db.Column(db.Float) # timestamp
uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'))
uploaded_at = db.Column(db.DateTime, default=datetime.utcnow)
deleted_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
deleted_at = db.Column(db.DateTime, default=datetime.utcnow)
room = db.relationship('Room', backref='trashed_files')
uploader = db.relationship('User', foreign_keys=[uploaded_by], backref='uploaded_trashed_files')
deleter = db.relationship('User', foreign_keys=[deleted_by], backref='deleted_trashed_files') # Changed from deleted_files to deleted_trashed_files
def __repr__(self):
return f'<TrashedFile {self.name} ({self.type}) from {self.original_path}>'

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
Flask==3.0.2
Flask-SQLAlchemy==3.1.1
Flask-Login==0.6.2
Flask-WTF==1.2.1
Flask-Migrate==4.0.5
SQLAlchemy==2.0.23
Werkzeug==3.0.1
WTForms==3.1.1
python-dotenv==1.0.1
psycopg2-binary==2.9.9
gunicorn==21.2.0

33
routes/__init__.py Normal file
View File

@@ -0,0 +1,33 @@
from flask import Blueprint, Flask, render_template
from flask_login import login_required
def init_app(app: Flask):
# Create blueprints
main_bp = Blueprint('main', __name__)
auth_bp = Blueprint('auth', __name__, url_prefix='/auth')
rooms_bp = Blueprint('rooms', __name__)
# Import and initialize routes
from .main import init_routes as init_main_routes
from .auth import init_routes as init_auth_routes
from .contacts import contacts_bp as contacts_routes
from .rooms import rooms_bp as rooms_routes
# Initialize routes
init_main_routes(main_bp)
init_auth_routes(auth_bp)
# Register blueprints
app.register_blueprint(main_bp)
app.register_blueprint(auth_bp)
app.register_blueprint(rooms_routes)
app.register_blueprint(contacts_routes)
@app.route('/rooms/<int:room_id>/trash')
@login_required
def trash_page(room_id):
return render_template('trash.html')
return app
# This file makes the routes directory a Python package

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

62
routes/auth.py Normal file
View File

@@ -0,0 +1,62 @@
from flask import render_template, request, flash, redirect, url_for
from flask_login import login_user, logout_user, login_required, current_user
from models import db, User
def init_routes(auth_bp):
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
email = request.form.get('email')
password = request.form.get('password')
remember = True if request.form.get('remember') else False
user = User.query.filter_by(email=email).first()
if not user or not user.check_password(password):
flash('Please check your login details and try again.', 'danger')
return redirect(url_for('auth.login'))
login_user(user, remember=remember)
return redirect(url_for('main.dashboard'))
return render_template('login.html')
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
if request.method == 'POST':
email = request.form.get('email')
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(email=email).first()
if user:
flash('Email address already exists', 'danger')
return redirect(url_for('auth.register'))
user = User.query.filter_by(username=username).first()
if user:
flash('Username already exists', 'danger')
return redirect(url_for('auth.register'))
new_user = User(email=email, username=username)
new_user.set_password(password)
db.session.add(new_user)
db.session.commit()
flash('Registration successful! Please login.', 'success')
return redirect(url_for('auth.login'))
return render_template('register.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
return redirect(url_for('main.home'))

264
routes/contacts.py Normal file
View File

@@ -0,0 +1,264 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from models import db, User
from forms import UserForm
from flask import abort
from sqlalchemy import or_
import json
import os
from werkzeug.utils import secure_filename
contacts_bp = Blueprint('contacts', __name__, url_prefix='/contacts')
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
def admin_required():
if not current_user.is_authenticated:
return redirect(url_for('auth.login'))
if not current_user.is_admin:
flash('You must be an admin to access this page.', 'error')
return redirect(url_for('main.dashboard'))
@contacts_bp.route('/')
@login_required
def contacts_list():
result = admin_required()
if result: return result
# Get query parameters
page = request.args.get('page', 1, type=int)
per_page = 10 # Number of items per page
search = request.args.get('search', '')
status = request.args.get('status', '')
role = request.args.get('role', '')
# Start with base query
query = User.query
# Apply search filter
if search:
search_term = f"%{search}%"
query = query.filter(
or_(
User.username.ilike(search_term),
User.last_name.ilike(search_term),
User.email.ilike(search_term),
User.company.ilike(search_term),
User.position.ilike(search_term)
)
)
# Apply status filter
if status == 'active':
query = query.filter(User.is_active == True)
elif status == 'inactive':
query = query.filter(User.is_active == False)
# Apply role filter
if role == 'admin':
query = query.filter(User.is_admin == True)
elif role == 'user':
query = query.filter(User.is_admin == False)
# Order by creation date
query = query.order_by(User.created_at.desc())
# Get pagination
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
users = pagination.items
return render_template('contacts/list.html',
users=users,
pagination=pagination,
current_user=current_user)
@contacts_bp.route('/new', methods=['GET', 'POST'])
@login_required
def new_contact():
result = admin_required()
if result: return result
form = UserForm()
total_admins = User.query.filter_by(is_admin=True).count()
if request.method == 'GET':
form.is_admin.data = False # Ensure admin role is unchecked by default
elif request.method == 'POST' and 'is_admin' not in request.form:
form.is_admin.data = False # Explicitly set to False if not present in POST
if form.validate_on_submit():
# Check if a user with this email already exists
existing_user = User.query.filter_by(email=form.email.data).first()
if existing_user:
flash('A user with this email already exists.', 'error')
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
# Handle profile picture upload
profile_picture = None
file = request.files.get('profile_picture')
if file and file.filename:
filename = secure_filename(file.filename)
file_path = os.path.join(UPLOAD_FOLDER, filename)
file.save(file_path)
profile_picture = filename
# Create new user account
user = User(
username=form.first_name.data,
last_name=form.last_name.data,
email=form.email.data,
phone=form.phone.data,
company=form.company.data,
position=form.position.data,
notes=form.notes.data,
is_active=form.is_active.data,
is_admin=form.is_admin.data,
profile_picture=profile_picture
)
user.set_password('changeme') # Set a default password that must be changed
db.session.add(user)
db.session.commit()
flash('User created successfully! They will need to set their password on first login.', 'success')
return redirect(url_for('contacts.contacts_list'))
return render_template('contacts/form.html', form=form, title='New User', total_admins=total_admins)
@contacts_bp.route('/profile/edit', methods=['GET', 'POST'])
@login_required
def edit_profile():
form = UserForm()
total_admins = User.query.filter_by(is_admin=True).count()
if form.validate_on_submit():
# Check if trying to remove admin status from the only admin
if not form.is_admin.data and current_user.is_admin:
if total_admins <= 1:
flash('There must be at least one admin user in the system.', 'error')
return render_template('contacts/form.html', form=form, title='Edit Profile', total_admins=total_admins)
current_user.username = form.first_name.data
current_user.last_name = form.last_name.data
current_user.email = form.email.data
current_user.phone = form.phone.data
current_user.company = form.company.data
current_user.position = form.position.data
current_user.notes = form.notes.data
current_user.is_active = form.is_active.data
current_user.is_admin = form.is_admin.data
# Set password if provided
if form.new_password.data:
current_user.set_password(form.new_password.data)
db.session.commit()
flash('Profile updated successfully!', 'success')
return redirect(url_for('contacts.contacts_list'))
# Pre-fill the form with current user data
if request.method == 'GET':
form.first_name.data = current_user.username
form.last_name.data = current_user.last_name
form.email.data = current_user.email
form.phone.data = current_user.phone
form.company.data = current_user.company
form.position.data = current_user.position
form.notes.data = current_user.notes
form.is_active.data = current_user.is_active
form.is_admin.data = current_user.is_admin
return render_template('contacts/form.html', form=form, title='Edit Profile', total_admins=total_admins)
@contacts_bp.route('/<int:id>/edit', methods=['GET', 'POST'])
@login_required
def edit_contact(id):
result = admin_required()
if result: return result
total_admins = User.query.filter_by(is_admin=True).count()
user = User.query.get_or_404(id)
form = UserForm()
if request.method == 'GET':
form.first_name.data = user.username
form.last_name.data = user.last_name
form.email.data = user.email
form.phone.data = user.phone
form.company.data = user.company
form.position.data = user.position
form.notes.data = user.notes
form.is_active.data = user.is_active
form.is_admin.data = user.is_admin
if form.validate_on_submit():
# Handle profile picture removal
if 'remove_picture' in request.form:
if user.profile_picture:
# Delete the old profile picture file
old_picture_path = os.path.join(UPLOAD_FOLDER, user.profile_picture)
if os.path.exists(old_picture_path):
os.remove(old_picture_path)
user.profile_picture = None
db.session.commit()
flash('Profile picture removed successfully!', 'success')
return redirect(url_for('contacts.edit_contact', id=user.id))
# Handle profile picture upload
file = request.files.get('profile_picture')
if file and file.filename:
# Delete old profile picture if it exists
if user.profile_picture:
old_picture_path = os.path.join(UPLOAD_FOLDER, user.profile_picture)
if os.path.exists(old_picture_path):
os.remove(old_picture_path)
filename = secure_filename(file.filename)
file_path = os.path.join(UPLOAD_FOLDER, filename)
file.save(file_path)
user.profile_picture = filename
# Prevent removing admin from the last admin
if not form.is_admin.data and user.is_admin and total_admins <= 1:
flash('There must be at least one admin user in the system.', 'error')
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
# Check if the new email is already used by another user
if form.email.data != user.email:
existing_user = User.query.filter_by(email=form.email.data).first()
if existing_user:
flash('A user with this email already exists.', 'error')
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
user.username = form.first_name.data
user.last_name = form.last_name.data
user.email = form.email.data
user.phone = form.phone.data
user.company = form.company.data
user.position = form.position.data
user.notes = form.notes.data
user.is_active = form.is_active.data
user.is_admin = form.is_admin.data
# Set password if provided
if form.new_password.data:
user.set_password(form.new_password.data)
db.session.commit()
flash('User updated successfully!', 'success')
return redirect(url_for('contacts.contacts_list'))
return render_template('contacts/form.html', form=form, title='Edit User', total_admins=total_admins, user=user)
@contacts_bp.route('/<int:id>/delete', methods=['POST'])
@login_required
def delete_contact(id):
result = admin_required()
if result: return result
user = User.query.get_or_404(id)
if user.email == current_user.email:
flash('You cannot delete your own account.', 'error')
return redirect(url_for('contacts.contacts_list'))
db.session.delete(user)
db.session.commit()
flash('User deleted successfully!', 'success')
return redirect(url_for('contacts.contacts_list'))
@contacts_bp.route('/<int:id>/toggle-active', methods=['POST'])
@login_required
def toggle_active(id):
result = admin_required()
if result: return result
user = User.query.get_or_404(id)
if user.email == current_user.email:
flash('You cannot deactivate your own account.', 'error')
return redirect(url_for('contacts.contacts_list'))
user.is_active = not user.is_active
db.session.commit()
flash(f'User marked as {"active" if user.is_active else "inactive"}!', 'success')
return redirect(url_for('contacts.contacts_list'))

284
routes/main.py Normal file
View File

@@ -0,0 +1,284 @@
from flask import render_template, Blueprint, redirect, url_for, request, flash
from flask_login import current_user, login_required
from models import User, db, Room, RoomFile, RoomMemberPermission
import os
from werkzeug.utils import secure_filename
from sqlalchemy import func, case, literal_column, text
from datetime import datetime, timedelta
def init_routes(main_bp):
@main_bp.route('/')
def home():
if current_user.is_authenticated:
return redirect(url_for('main.dashboard'))
return render_template('home.html')
@main_bp.route('/dashboard')
@login_required
def dashboard():
# Get 3 most recent users
recent_contacts = User.query.order_by(User.created_at.desc()).limit(3).all()
# Count active and inactive users
active_count = User.query.filter_by(is_active=True).count()
inactive_count = User.query.filter_by(is_active=False).count()
# Room count and size logic
if current_user.is_admin:
room_count = Room.query.count()
# Get total file and folder counts for admin
file_count = RoomFile.query.filter_by(type='file').count()
folder_count = RoomFile.query.filter_by(type='folder').count()
# Get total size of all files including trash
total_size = db.session.query(func.sum(RoomFile.size)).filter(RoomFile.type == 'file').scalar() or 0
# Get recent activity for all files
recent_activity = db.session.query(
RoomFile,
Room,
User
).join(
Room, RoomFile.room_id == Room.id
).join(
User, RoomFile.uploaded_by == User.id
).order_by(
RoomFile.uploaded_at.desc()
).limit(10).all()
# Format the activity data
formatted_activity = []
for file, room, user in recent_activity:
activity = {
'name': file.name,
'type': file.type,
'room': room,
'uploader': user,
'uploaded_at': file.uploaded_at,
'is_starred': current_user in file.starred_by,
'is_deleted': file.deleted,
'can_download': True # Admin can download everything
}
formatted_activity.append(activity)
recent_activity = formatted_activity
# Get storage usage by file type including trash
storage_by_type = db.session.query(
case(
(RoomFile.name.like('%.%'),
func.split_part(RoomFile.name, '.', -1)),
else_=literal_column("'unknown'")
).label('extension'),
func.count(RoomFile.id).label('count'),
func.sum(RoomFile.size).label('total_size')
).filter(
RoomFile.type == 'file'
).group_by('extension').all()
# Get trash and starred stats for admin
trash_count = RoomFile.query.filter_by(deleted=True).count()
starred_count = RoomFile.query.filter(RoomFile.starred_by.contains(current_user)).count()
# Get oldest trash date and total trash size
oldest_trash = RoomFile.query.filter_by(deleted=True).order_by(RoomFile.deleted_at.asc()).first()
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash else None
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.deleted==True).scalar() or 0
# Get files that will be deleted in next 7 days
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
pending_deletion = RoomFile.query.filter(
RoomFile.deleted==True,
RoomFile.deleted_at <= thirty_days_ago,
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
).count()
# Get trash file type breakdown
trash_by_type = db.session.query(
case(
(RoomFile.name.like('%.%'),
func.split_part(RoomFile.name, '.', -1)),
else_=literal_column("'unknown'")
).label('extension'),
func.count(RoomFile.id).label('count')
).filter(
RoomFile.deleted==True
).group_by('extension').all()
else:
# Get rooms the user has access to
accessible_rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
room_count = len(accessible_rooms)
# Get file and folder counts for accessible rooms
room_ids = [room.id for room in accessible_rooms]
file_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'file').count()
folder_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.type == 'folder').count()
# Get total size of files in accessible rooms including trash
total_size = db.session.query(func.sum(RoomFile.size)).filter(
RoomFile.room_id.in_(room_ids),
RoomFile.type == 'file'
).scalar() or 0
# Get recent activity for accessible rooms
recent_activity = db.session.query(
RoomFile,
Room,
User
).join(
Room, RoomFile.room_id == Room.id
).join(
User, RoomFile.uploaded_by == User.id
).filter(
RoomFile.room_id.in_(room_ids)
).order_by(
RoomFile.uploaded_at.desc()
).limit(10).all()
# Format the activity data
formatted_activity = []
user_perms = {p.room_id: p for p in RoomMemberPermission.query.filter(
RoomMemberPermission.room_id.in_(room_ids),
RoomMemberPermission.user_id==current_user.id
).all()}
for file, room, user in recent_activity:
perm = user_perms.get(room.id)
activity = {
'name': file.name,
'type': file.type,
'room': room,
'uploader': user,
'uploaded_at': file.uploaded_at,
'is_starred': current_user in file.starred_by,
'is_deleted': file.deleted,
'can_download': perm.can_download if perm else False
}
formatted_activity.append(activity)
recent_activity = formatted_activity
# Get storage usage by file type for accessible rooms including trash
storage_by_type = db.session.query(
case(
(RoomFile.name.like('%.%'),
func.split_part(RoomFile.name, '.', -1)),
else_=literal_column("'unknown'")
).label('extension'),
func.count(RoomFile.id).label('count'),
func.sum(RoomFile.size).label('total_size')
).filter(
RoomFile.room_id.in_(room_ids),
RoomFile.type == 'file'
).group_by('extension').all()
# Get trash and starred stats for user's accessible rooms
trash_count = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).count()
starred_count = RoomFile.query.filter(
RoomFile.room_id.in_(room_ids),
RoomFile.starred_by.contains(current_user)
).count()
# Get oldest trash date and total trash size for user's rooms
oldest_trash = RoomFile.query.filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).order_by(RoomFile.deleted_at.asc()).first()
oldest_trash_date = oldest_trash.deleted_at.strftime('%Y-%m-%d') if oldest_trash else None
trash_size = db.session.query(db.func.sum(RoomFile.size)).filter(RoomFile.room_id.in_(room_ids), RoomFile.deleted==True).scalar() or 0
# Get files that will be deleted in next 7 days for user's rooms
seven_days_from_now = datetime.utcnow() + timedelta(days=7)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
pending_deletion = RoomFile.query.filter(
RoomFile.room_id.in_(room_ids),
RoomFile.deleted==True,
RoomFile.deleted_at <= thirty_days_ago,
RoomFile.deleted_at > thirty_days_ago - timedelta(days=7)
).count()
# Get trash file type breakdown for user's rooms
trash_by_type = db.session.query(
case(
(RoomFile.name.like('%.%'),
func.split_part(RoomFile.name, '.', -1)),
else_=literal_column("'unknown'")
).label('extension'),
func.count(RoomFile.id).label('count')
).filter(
RoomFile.room_id.in_(room_ids),
RoomFile.deleted==True
).group_by('extension').all()
return render_template('dashboard.html',
recent_contacts=recent_contacts,
active_count=active_count,
inactive_count=inactive_count,
room_count=room_count,
file_count=file_count,
folder_count=folder_count,
total_size=total_size,
recent_activity=recent_activity,
storage_by_type=storage_by_type,
trash_count=trash_count,
starred_count=starred_count,
oldest_trash_date=oldest_trash_date,
trash_size=trash_size,
pending_deletion=pending_deletion,
trash_by_type=trash_by_type)
UPLOAD_FOLDER = os.path.join(os.getcwd(), 'uploads', 'profile_pics')
if not os.path.exists(UPLOAD_FOLDER):
os.makedirs(UPLOAD_FOLDER)
@main_bp.route('/profile', methods=['GET', 'POST'])
@login_required
def profile():
if request.method == 'POST':
# Handle profile picture removal
if 'remove_picture' in request.form:
if current_user.profile_picture:
# Delete the old profile picture file
old_picture_path = os.path.join(UPLOAD_FOLDER, current_user.profile_picture)
if os.path.exists(old_picture_path):
os.remove(old_picture_path)
current_user.profile_picture = None
db.session.commit()
flash('Profile picture removed successfully!', 'success')
return redirect(url_for('main.profile'))
new_email = request.form.get('email')
# Check if the new email is already used by another user
if new_email != current_user.email:
existing_user = User.query.filter_by(email=new_email).first()
if existing_user:
flash('A user with this email already exists.', 'error')
return render_template('profile.html')
# Handle profile picture upload
file = request.files.get('profile_picture')
if file and file.filename:
filename = secure_filename(file.filename)
file_path = os.path.join(UPLOAD_FOLDER, filename)
file.save(file_path)
current_user.profile_picture = filename
# Update user information
current_user.username = request.form.get('first_name')
current_user.last_name = request.form.get('last_name')
current_user.email = new_email
current_user.phone = request.form.get('phone')
current_user.company = request.form.get('company')
current_user.position = request.form.get('position')
current_user.notes = request.form.get('notes')
# Handle password change if provided
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
if new_password:
if new_password != confirm_password:
flash('Passwords do not match.', 'error')
return render_template('profile.html')
current_user.set_password(new_password)
flash('Password updated successfully.', 'success')
try:
db.session.commit()
flash('Profile updated successfully!', 'success')
except Exception as e:
db.session.rollback()
flash('An error occurred while updating your profile.', 'error')
return redirect(url_for('main.profile'))
return render_template('profile.html')
@main_bp.route('/starred')
@login_required
def starred():
return render_template('starred.html')
@main_bp.route('/trash')
@login_required
def trash():
return render_template('trash.html')

668
routes/room_files.py Normal file
View File

@@ -0,0 +1,668 @@
from flask import Blueprint, jsonify, request, abort, send_from_directory, send_file
from flask_login import login_required, current_user
import os
from models import Room, RoomMemberPermission, RoomFile, TrashedFile, db
from werkzeug.utils import secure_filename, safe_join
import time
import shutil
import io
import zipfile
from datetime import datetime
room_files_bp = Blueprint('room_files', __name__, url_prefix='/api/rooms')
DATA_ROOT = '/data/rooms' # This should be a Docker volume
ALLOWED_EXTENSIONS = {
# Documents
'pdf', 'docx', 'doc', 'txt', 'rtf', 'odt', 'md', 'csv',
# Spreadsheets
'xlsx', 'xls', 'ods', 'xlsm',
# Presentations
'pptx', 'ppt', 'odp',
# Images
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff',
# Archives
'zip', 'rar', '7z', 'tar', 'gz',
# Code/Text
'py', 'js', 'html', 'css', 'json', 'xml', 'sql', 'sh', 'bat',
# Audio
'mp3', 'wav', 'ogg', 'm4a', 'flac',
# Video
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm',
# CAD/Design
'dwg', 'dxf', 'ai', 'psd', 'eps', 'indd',
# Other
'eml', 'msg', 'vcf', 'ics'
}
def get_room_dir(room_id):
return os.path.join(DATA_ROOT, str(room_id))
def user_has_permission(room, perm_name):
if current_user.is_admin:
return True
perm = RoomMemberPermission.query.filter_by(room_id=room.id, user_id=current_user.id).first()
return getattr(perm, perm_name, False) if perm else False
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def clean_path(path):
if not path:
return ''
return path.strip('/\\')
@room_files_bp.route('/<int:room_id>/files', methods=['GET'])
@login_required
def list_room_files(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
path = request.args.get('path', '')
path = clean_path(path)
# Get files in the current path
files = RoomFile.query.filter_by(room_id=room_id, path=path, deleted=False).all()
# Debug: Check user permissions
if not current_user.is_admin:
perm = RoomMemberPermission.query.filter_by(room_id=room.id, user_id=current_user.id).first()
print("=== User Permissions ===")
print(f" - can_view: {getattr(perm, 'can_view', False) if perm else False}")
print(f" - can_download: {getattr(perm, 'can_download', False) if perm else False}")
print(f" - can_upload: {getattr(perm, 'can_upload', False) if perm else False}")
print(f" - can_delete: {getattr(perm, 'can_delete', False) if perm else False}")
print(f" - can_rename: {getattr(perm, 'can_rename', False) if perm else False}")
print(f" - can_move: {getattr(perm, 'can_move', False) if perm else False}")
print(f" - can_share: {getattr(perm, 'can_share', False) if perm else False}")
print("-------------------")
result = []
for f in files:
uploader_full_name = None
uploader_profile_pic = None
if f.uploader:
uploader_full_name = f.uploader.username
if getattr(f.uploader, 'last_name', None):
uploader_full_name += ' ' + f.uploader.last_name
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
result.append({
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': uploader_full_name,
'uploader_profile_pic': uploader_profile_pic,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'path': f.path,
'starred': current_user in f.starred_by
})
print(f"Returning {len(result)} files") # Debug log
return jsonify(result)
@room_files_bp.route('/<int:room_id>/files/upload', methods=['POST'])
@login_required
def upload_room_file(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_upload'):
abort(403)
if 'file' not in request.files:
return jsonify({'error': 'No file part'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No selected file'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'File type not allowed'}), 400
filename = secure_filename(file.filename)
room_dir = get_room_dir(room_id)
rel_path = clean_path(request.form.get('path', ''))
target_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
os.makedirs(target_dir, exist_ok=True)
file_path = os.path.join(target_dir, filename)
# Check for overwrite flag
overwrite = request.form.get('overwrite', 'false').lower() == 'true'
# First check for non-deleted files
existing_file = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path, deleted=False).first()
if existing_file and not overwrite:
return jsonify({'error': 'A file with this name already exists in this location', 'conflict': True}), 409
# Then check for deleted files
trashed_file = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path, deleted=True).first()
if trashed_file:
# If we're not overwriting, return conflict
if not overwrite:
return jsonify({'error': 'A file with this name exists in the trash', 'conflict': True}), 409
# If we are overwriting, delete the trashed file record
db.session.delete(trashed_file)
db.session.commit()
existing_file = None
file.save(file_path)
stat = os.stat(file_path)
if existing_file:
# Overwrite: update the RoomFile record
existing_file.size = stat.st_size
existing_file.modified = stat.st_mtime
existing_file.uploaded_by = current_user.id
existing_file.uploaded_at = datetime.utcnow()
db.session.commit()
return jsonify({'success': True, 'filename': filename, 'overwritten': True})
else:
rf = RoomFile(
room_id=room_id,
name=filename,
path=rel_path,
type='file',
size=stat.st_size,
modified=stat.st_mtime,
uploaded_by=current_user.id,
uploaded_at=datetime.utcnow()
)
db.session.add(rf)
db.session.commit()
return jsonify({'success': True, 'filename': filename})
@room_files_bp.route('/<int:room_id>/files/<filename>', methods=['GET'])
@login_required
def download_room_file(room_id, filename):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_download'):
abort(403)
rel_path = clean_path(request.args.get('path', ''))
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
if not rf or rf.type != 'file':
return jsonify({'error': 'File not found'}), 404
room_dir = get_room_dir(room_id)
file_path = os.path.join(room_dir, rel_path, filename) if rel_path else os.path.join(room_dir, filename)
if not os.path.exists(file_path):
return jsonify({'error': 'File not found'}), 404
return send_from_directory(os.path.dirname(file_path), filename, as_attachment=True)
@room_files_bp.route('/<int:room_id>/files/<path:filename>', methods=['DELETE'])
@login_required
def delete_file(room_id, filename):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_delete'):
abort(403)
rel_path = clean_path(request.args.get('path', ''))
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
if not rf:
return jsonify({'error': 'File not found'}), 404
# Mark as deleted and record who deleted it and when
rf.deleted = True
rf.deleted_by = current_user.id
rf.deleted_at = datetime.utcnow()
db.session.commit()
return jsonify({'success': True})
@room_files_bp.route('/<int:room_id>/folders', methods=['POST'])
@login_required
def create_room_folder(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_upload'):
abort(403)
data = request.get_json()
folder_name = data.get('name', '').strip()
rel_path = clean_path(data.get('path', ''))
if not folder_name or '/' in folder_name or '\\' in folder_name or folder_name.startswith('.'):
return jsonify({'error': 'Invalid folder name'}), 400
room_dir = get_room_dir(room_id)
target_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
os.makedirs(target_dir, exist_ok=True)
folder_path = os.path.join(target_dir, folder_name)
# First check for trashed folder
trashed_folder = RoomFile.query.filter_by(
room_id=room_id,
name=folder_name,
path=rel_path,
type='folder',
deleted=True
).first()
if trashed_folder:
return jsonify({'error': 'A folder with this name exists in the trash'}), 409
# Then check for existing folder in current location
existing_folder = RoomFile.query.filter_by(
room_id=room_id,
name=folder_name,
path=rel_path,
type='folder',
deleted=False
).first()
if existing_folder:
return jsonify({'error': 'A folder with this name already exists in this location'}), 400
if os.path.exists(folder_path):
return jsonify({'error': 'A folder with this name already exists in this location'}), 400
os.makedirs(folder_path)
# Add RoomFile entry
stat = os.stat(folder_path)
rf = RoomFile(
room_id=room_id,
name=folder_name,
path=rel_path,
type='folder',
size=None,
modified=stat.st_mtime,
uploaded_by=current_user.id,
uploaded_at=datetime.utcnow()
)
db.session.add(rf)
db.session.commit()
return jsonify({'success': True, 'name': folder_name})
@room_files_bp.route('/<int:room_id>/rename', methods=['POST'])
@login_required
def rename_room_file(room_id):
room = Room.query.get_or_404(room_id)
# Allow rename if user can upload or delete
if not (user_has_permission(room, 'can_upload') or user_has_permission(room, 'can_delete')):
abort(403)
data = request.get_json()
old_name = data.get('old_name', '').strip()
new_name = data.get('new_name', '').strip()
rel_path = clean_path(data.get('path', ''))
if not old_name or not new_name or '/' in new_name or '\\' in new_name or new_name.startswith('.'):
return jsonify({'error': 'Invalid name'}), 400
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=old_name, path=rel_path).first()
if not rf:
return jsonify({'error': 'Original file/folder not found'}), 404
room_dir = get_room_dir(room_id)
base_dir = os.path.join(room_dir, rel_path) if rel_path else room_dir
old_path = os.path.join(base_dir, old_name)
new_path = os.path.join(base_dir, new_name)
if not os.path.exists(old_path):
return jsonify({'error': 'Original file/folder not found'}), 404
if os.path.exists(new_path):
return jsonify({'error': 'A file or folder with the new name already exists'}), 400
# Prevent file extension change for files
if os.path.isfile(old_path):
old_ext = os.path.splitext(old_name)[1].lower()
new_ext = os.path.splitext(new_name)[1].lower()
if old_ext != new_ext:
return jsonify({'error': 'File extension cannot be changed'}), 400
os.rename(old_path, new_path)
# Update RoomFile entry
rf.name = new_name
rf.modified = os.path.getmtime(new_path)
db.session.commit()
return jsonify({'success': True, 'old_name': old_name, 'new_name': new_name})
@room_files_bp.route('/<int:room_id>/download-zip', methods=['POST'])
@login_required
def download_zip(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
data = request.get_json()
items = data.get('items', [])
if not items or not isinstance(items, list):
return jsonify({'error': 'No items selected'}), 400
room_dir = get_room_dir(room_id)
mem_zip = io.BytesIO()
with zipfile.ZipFile(mem_zip, 'w', zipfile.ZIP_DEFLATED) as zf:
for item in items:
name = item.get('name')
rel_path = item.get('path', '').strip('/\\')
abs_path = os.path.join(room_dir, rel_path, name) if rel_path else os.path.join(room_dir, name)
if not os.path.exists(abs_path):
continue
if os.path.isfile(abs_path):
zf.write(abs_path, arcname=name)
elif os.path.isdir(abs_path):
for root, dirs, files in os.walk(abs_path):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, room_dir)
zf.write(file_path, arcname=arcname)
mem_zip.seek(0)
return send_file(mem_zip, mimetype='application/zip', as_attachment=True, download_name='download.zip')
@room_files_bp.route('/<int:room_id>/search', methods=['GET'])
@login_required
def search_room_files(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
query = request.args.get('query', '').strip().lower()
# Search RoomFile for this room
files = RoomFile.query.filter(RoomFile.room_id==room_id).all()
matches = []
for f in files:
if query in f.name.lower():
matches.append({
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': f.uploader.username if f.uploader else None,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'path': f.path,
})
return jsonify(matches)
@room_files_bp.route('/<int:room_id>/move', methods=['POST'])
@login_required
def move_room_file(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_move'):
abort(403)
data = request.get_json()
filename = data.get('filename', '').strip()
source_path = clean_path(data.get('source_path', ''))
target_path = clean_path(data.get('target_path', ''))
if not filename or '/' in filename or '\\' in filename or filename.startswith('.'):
return jsonify({'error': 'Invalid filename'}), 400
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=source_path).first()
if not rf:
return jsonify({'error': 'File not found'}), 404
room_dir = get_room_dir(room_id)
source_dir = os.path.join(room_dir, source_path) if source_path else room_dir
target_dir = os.path.join(room_dir, target_path) if target_path else room_dir
source_file_path = os.path.join(source_dir, filename)
target_file_path = os.path.join(target_dir, filename)
if not os.path.exists(source_file_path):
return jsonify({'error': 'Source file not found'}), 404
if os.path.exists(target_file_path):
return jsonify({'error': 'A file with this name already exists in the target location'}), 400
# Create target directory if it doesn't exist
os.makedirs(target_dir, exist_ok=True)
# Move the file
shutil.move(source_file_path, target_file_path)
# Update RoomFile entry
rf.path = target_path
rf.modified = os.path.getmtime(target_file_path)
db.session.commit()
return jsonify({'success': True})
@room_files_bp.route('/<int:room_id>/folders', methods=['GET'])
@login_required
def list_room_folders(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
# Get all files in the room that are not deleted
files = RoomFile.query.filter_by(room_id=room_id, deleted=False).all()
# Extract unique folder paths
folders = set()
for f in files:
if f.type == 'folder':
full_path = f.path + '/' + f.name if f.path else f.name
folders.add(full_path)
# Convert to sorted list
folder_list = sorted(list(folders))
return jsonify(folder_list)
@room_files_bp.route('/<int:room_id>/star', methods=['POST'])
@login_required
def toggle_star(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
data = request.get_json()
filename = data.get('filename', '').strip()
rel_path = clean_path(data.get('path', ''))
if not filename:
return jsonify({'error': 'Invalid filename'}), 400
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
if not rf:
return jsonify({'error': 'File not found'}), 404
# Check if the file is already starred by this user
is_starred = current_user in rf.starred_by
if is_starred:
# Unstar the file
rf.starred_by.remove(current_user)
else:
# Star the file
rf.starred_by.append(current_user)
db.session.commit()
return jsonify({'success': True, 'starred': not is_starred})
@room_files_bp.route('/<int:room_id>/starred', methods=['GET'])
@login_required
def get_starred_files(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
# Get all starred files in the room
files = RoomFile.query.filter_by(room_id=room_id, starred=True).all()
result = []
for f in files:
uploader_full_name = None
uploader_profile_pic = None
if f.uploader:
uploader_full_name = f.uploader.username
if getattr(f.uploader, 'last_name', None):
uploader_full_name += ' ' + f.uploader.last_name
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
result.append({
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': uploader_full_name,
'uploader_profile_pic': uploader_profile_pic,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'path': f.path,
'starred': f.starred
})
return jsonify(result)
@room_files_bp.route('/starred', methods=['GET'])
@login_required
def get_all_starred_files():
# Get all rooms the user has access to
if current_user.is_admin:
rooms = Room.query.all()
else:
rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
room_ids = [room.id for room in rooms]
room_names = {room.id: room.name for room in rooms}
# Get all files starred by the current user from accessible rooms
files = RoomFile.query.filter(
RoomFile.room_id.in_(room_ids),
RoomFile.starred_by.contains(current_user)
).all()
result = []
for f in files:
uploader_full_name = None
uploader_profile_pic = None
if f.uploader:
uploader_full_name = f.uploader.username
if getattr(f.uploader, 'last_name', None):
uploader_full_name += ' ' + f.uploader.last_name
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
result.append({
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': uploader_full_name,
'uploader_profile_pic': uploader_profile_pic,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'path': f.path,
'starred': True,
'room_id': f.room_id,
'room_name': room_names.get(f.room_id)
})
return jsonify(result)
@room_files_bp.route('/trash', methods=['GET'])
@login_required
def get_trash_files():
# Get all rooms the user has access to
if current_user.is_admin:
rooms = Room.query.all()
else:
rooms = Room.query.filter(Room.members.any(id=current_user.id)).all()
room_ids = [room.id for room in rooms]
room_names = {room.id: room.name for room in rooms}
# Get all deleted files from accessible rooms
files = RoomFile.query.filter(
RoomFile.room_id.in_(room_ids),
RoomFile.deleted == True
).all()
result = []
for f in files:
uploader_full_name = None
uploader_profile_pic = None
if f.uploader:
uploader_full_name = f.uploader.username
if getattr(f.uploader, 'last_name', None):
uploader_full_name += ' ' + f.uploader.last_name
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
deleter_full_name = None
if f.deleter:
deleter_full_name = f.deleter.username
if getattr(f.deleter, 'last_name', None):
deleter_full_name += ' ' + f.deleter.last_name
# Check if user has delete permission in this room
room = Room.query.get(f.room_id)
has_delete_permission = user_has_permission(room, 'can_delete') if room else False
# Check if user can restore this file
can_restore = has_delete_permission and f.deleted_by == current_user.id
result.append({
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': uploader_full_name,
'uploader_profile_pic': uploader_profile_pic,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'path': f.path,
'room_id': f.room_id,
'room_name': room_names.get(f.room_id),
'deleted_by': deleter_full_name,
'deleted_at': f.deleted_at.isoformat() if f.deleted_at else None,
'can_restore': can_restore
})
return jsonify(result)
@room_files_bp.route('/<int:room_id>/restore', methods=['POST'])
@login_required
def restore_file(room_id):
room = Room.query.get_or_404(room_id)
# Check for delete permission instead of view permission
if not user_has_permission(room, 'can_delete'):
abort(403)
data = request.get_json()
filename = data.get('filename', '').strip()
rel_path = clean_path(data.get('path', ''))
if not filename:
return jsonify({'error': 'Invalid filename'}), 400
# Lookup in RoomFile
rf = RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()
if not rf:
return jsonify({'error': 'File not found'}), 404
# Check if the current user was the one who deleted the file
if rf.deleted_by != current_user.id:
return jsonify({'error': 'You can only restore files that you deleted'}), 403
# Restore file by setting deleted to False
rf.deleted = False
db.session.commit()
return jsonify({'success': True})
@room_files_bp.route('/<int:room_id>/delete-permanent', methods=['POST'])
@login_required
def delete_permanent(room_id):
# Only allow admin users to permanently delete files
if not current_user.is_admin:
abort(403)
room = Room.query.get_or_404(room_id)
data = request.get_json()
filename = data.get('filename', '').strip()
rel_path = clean_path(data.get('path', ''))
if not filename:
return jsonify({'error': 'Invalid filename'}), 400
# If filename is '*', delete all deleted files in the room
if filename == '*':
files_to_delete = RoomFile.query.filter_by(room_id=room_id, deleted=True).all()
else:
# Lookup specific file
files_to_delete = [RoomFile.query.filter_by(room_id=room_id, name=filename, path=rel_path).first()]
if not files_to_delete[0]:
return jsonify({'error': 'File not found'}), 404
for rf in files_to_delete:
if not rf:
continue
# Delete the file from storage if it's a file
if rf.type == 'file':
try:
file_path = os.path.join(get_room_dir(room_id), rf.path, rf.name)
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
print(f"Error deleting file from storage: {e}")
# Delete the database record
db.session.delete(rf)
db.session.commit()
return jsonify({'success': True})

121
routes/room_members.py Normal file
View File

@@ -0,0 +1,121 @@
from flask import Blueprint, jsonify, request, abort
from flask_login import login_required, current_user
from models import db, Room, User, RoomMemberPermission
from utils import user_has_permission
room_members_bp = Blueprint('room_members', __name__)
@room_members_bp.route('/<int:room_id>/members', methods=['GET'])
@login_required
def list_room_members(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
members = []
for member in room.members:
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=member.id).first()
members.append({
'id': member.id,
'username': member.username,
'last_name': member.last_name,
'email': member.email,
'profile_picture': member.profile_picture,
'permissions': {
'can_view': permission.can_view if permission else False,
'can_download': permission.can_download if permission else False,
'can_upload': permission.can_upload if permission else False,
'can_delete': permission.can_delete if permission else False,
'can_rename': permission.can_rename if permission else False,
'can_move': permission.can_move if permission else False,
'can_share': permission.can_share if permission else False
}
})
return jsonify(members)
@room_members_bp.route('/<int:room_id>/members', methods=['POST'])
@login_required
def add_room_member(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_share'):
abort(403)
data = request.get_json()
user_id = data.get('user_id')
permissions = data.get('permissions', {})
if not user_id:
return jsonify({'error': 'User ID is required'}), 400
user = User.query.get_or_404(user_id)
# Add user to room members
if user not in room.members:
room.members.append(user)
# Update permissions
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if not permission:
permission = RoomMemberPermission(room_id=room_id, user_id=user_id)
db.session.add(permission)
permission.can_view = permissions.get('can_view', True)
permission.can_download = permissions.get('can_download', False)
permission.can_upload = permissions.get('can_upload', False)
permission.can_delete = permissions.get('can_delete', False)
permission.can_rename = permissions.get('can_rename', False)
permission.can_move = permissions.get('can_move', False)
permission.can_share = permissions.get('can_share', False)
db.session.commit()
return jsonify({'success': True})
@room_members_bp.route('/<int:room_id>/members/<int:user_id>', methods=['DELETE'])
@login_required
def remove_room_member(room_id, user_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_share'):
abort(403)
user = User.query.get_or_404(user_id)
# Remove user from room members
if user in room.members:
room.members.remove(user)
# Remove permissions
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if permission:
db.session.delete(permission)
db.session.commit()
return jsonify({'success': True})
@room_members_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['PUT'])
@login_required
def update_member_permissions(room_id, user_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_share'):
abort(403)
data = request.get_json()
permissions = data.get('permissions', {})
permission = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if not permission:
return jsonify({'error': 'User is not a member of this room'}), 404
permission.can_view = permissions.get('can_view', permission.can_view)
permission.can_download = permissions.get('can_download', permission.can_download)
permission.can_upload = permissions.get('can_upload', permission.can_upload)
permission.can_delete = permissions.get('can_delete', permission.can_delete)
permission.can_rename = permissions.get('can_rename', permission.can_rename)
permission.can_move = permissions.get('can_move', permission.can_move)
permission.can_share = permissions.get('can_share', permission.can_share)
db.session.commit()
return jsonify({'success': True})

223
routes/rooms.py Normal file
View File

@@ -0,0 +1,223 @@
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from models import db, Room, User, RoomMemberPermission, RoomFile
from forms import RoomForm
from routes.room_files import user_has_permission
rooms_bp = Blueprint('rooms', __name__, url_prefix='/rooms')
@rooms_bp.route('/')
@login_required
def rooms():
search = request.args.get('search', '').strip()
if current_user.is_admin:
query = Room.query
else:
query = Room.query.filter(Room.members.any(id=current_user.id))
if search:
query = query.filter(Room.name.ilike(f'%{search}%'))
rooms = query.order_by(Room.created_at.desc()).all()
return render_template('rooms.html', rooms=rooms, search=search)
@rooms_bp.route('/create', methods=['GET', 'POST'])
@login_required
def create_room():
form = RoomForm()
if form.validate_on_submit():
room = Room(
name=form.name.data,
description=form.description.data,
created_by=current_user.id
)
# Add creator as a member with full permissions
room.members.append(current_user)
creator_permission = RoomMemberPermission(
room=room,
user=current_user,
can_view=True,
can_upload=True,
can_delete=True,
can_share=True
)
db.session.add(room)
db.session.add(creator_permission)
db.session.commit()
flash('Room created successfully!', 'success')
return redirect(url_for('rooms.rooms'))
return render_template('create_room.html', form=form)
@rooms_bp.route('/<int:room_id>')
@login_required
def room(room_id):
room = Room.query.get_or_404(room_id)
# Admins always have access
if not current_user.is_admin:
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member:
flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms'))
can_download = user_has_permission(room, 'can_download')
can_upload = user_has_permission(room, 'can_upload')
can_delete = user_has_permission(room, 'can_delete')
can_rename = user_has_permission(room, 'can_rename')
can_move = user_has_permission(room, 'can_move')
can_share = user_has_permission(room, 'can_share')
return render_template('room.html', room=room, can_download=can_download, can_upload=can_upload, can_delete=can_delete, can_rename=can_rename, can_move=can_move, can_share=can_share)
@rooms_bp.route('/<int:room_id>/members')
@login_required
def room_members(room_id):
room = Room.query.get_or_404(room_id)
# Admins always have access
if not current_user.is_admin:
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member:
flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms'))
if not current_user.is_admin:
flash('Only administrators can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id))
member_permissions = {p.user_id: p for p in room.member_permissions}
available_users = User.query.filter(~User.id.in_(member_permissions.keys())).all()
return render_template('room_members.html', room=room, available_users=available_users, member_permissions=member_permissions)
@rooms_bp.route('/<int:room_id>/members/add', methods=['POST'])
@login_required
def add_member(room_id):
room = Room.query.get_or_404(room_id)
# Membership check using RoomMemberPermission
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member:
flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms'))
if not current_user.is_admin:
flash('Only administrators can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id))
user_id = request.form.get('user_id')
if not user_id:
flash('Please select a user to add.', 'error')
return redirect(url_for('rooms.room_members', room_id=room_id))
user = User.query.get_or_404(user_id)
# Check if already a member
if RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user.id).first():
flash('User is already a member of this room.', 'error')
else:
perm = RoomMemberPermission(room_id=room_id, user_id=user.id, can_view=True)
db.session.add(perm)
# Ensure user is added to the room.members relationship
if user not in room.members:
room.members.append(user)
db.session.commit()
flash(f'{user.username} has been added to the room.', 'success')
return redirect(url_for('rooms.room_members', room_id=room_id))
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/remove', methods=['POST'])
@login_required
def remove_member(room_id, user_id):
room = Room.query.get_or_404(room_id)
# Membership check using RoomMemberPermission
is_member = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=current_user.id).first() is not None
if not is_member:
flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms'))
if not current_user.is_admin:
flash('Only administrators can manage room members.', 'error')
return redirect(url_for('rooms.room', room_id=room_id))
if user_id == room.created_by:
flash('Cannot remove the room creator.', 'error')
else:
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if not perm:
flash('User is not a member of this room.', 'error')
else:
db.session.delete(perm)
db.session.commit()
flash('User has been removed from the room.', 'success')
return redirect(url_for('rooms.room_members', room_id=room_id))
@rooms_bp.route('/<int:room_id>/members/<int:user_id>/permissions', methods=['POST'])
@login_required
def update_member_permissions(room_id, user_id):
room = Room.query.get_or_404(room_id)
if not current_user.is_admin:
flash('Only administrators can update permissions.', 'error')
return redirect(url_for('rooms.room_members', room_id=room_id))
perm = RoomMemberPermission.query.filter_by(room_id=room_id, user_id=user_id).first()
if not perm:
flash('Member not found.', 'error')
return redirect(url_for('rooms.room_members', room_id=room_id))
perm.can_view = bool(request.form.get('can_view'))
perm.can_download = bool(request.form.get('can_download'))
perm.can_upload = bool(request.form.get('can_upload'))
perm.can_delete = bool(request.form.get('can_delete'))
perm.can_rename = bool(request.form.get('can_rename'))
perm.can_move = bool(request.form.get('can_move'))
perm.can_share = bool(request.form.get('can_share'))
db.session.commit()
flash('Permissions updated.', 'success')
return redirect(url_for('rooms.room_members', room_id=room_id))
@rooms_bp.route('/<int:room_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_room(room_id):
if not current_user.is_admin:
flash('Only administrators can edit rooms.', 'error')
return redirect(url_for('rooms.rooms'))
room = Room.query.get_or_404(room_id)
form = RoomForm()
if form.validate_on_submit():
room.name = form.name.data
room.description = form.description.data
db.session.commit()
flash('Room updated successfully!', 'success')
return redirect(url_for('rooms.rooms'))
# Pre-populate form with existing room data
if request.method == 'GET':
form.name.data = room.name
form.description.data = room.description
return render_template('edit_room.html', form=form, room=room)
@rooms_bp.route('/<int:room_id>/delete', methods=['POST'])
@login_required
def delete_room(room_id):
if not current_user.is_admin:
flash('Only administrators can delete rooms.', 'error')
return redirect(url_for('rooms.rooms'))
room = Room.query.get_or_404(room_id)
room_name = room.name
try:
print(f"Attempting to delete room {room_id} ({room_name})")
# Delete the room (cascade will handle the rest)
db.session.delete(room)
db.session.commit()
print("Room deleted successfully")
flash(f'Room "{room_name}" has been deleted.', 'success')
except Exception as e:
db.session.rollback()
flash('An error occurred while deleting the room. Please try again.', 'error')
print(f"Error deleting room: {str(e)}")
return redirect(url_for('rooms.rooms'))
@rooms_bp.route('/room/<int:room_id>/view/<path:file_path>')
@login_required
def view_file(room_id, file_path):
room = Room.query.get_or_404(room_id)
# Check if user has access to the room
if not current_user.is_admin and current_user not in room.members:
flash('You do not have access to this room.', 'error')
return redirect(url_for('rooms.rooms'))
file = RoomFile.query.filter_by(room_id=room_id, path=file_path).first_or_404()
# Continue with file viewing logic...

110
routes/trash.py Normal file
View File

@@ -0,0 +1,110 @@
from flask import Blueprint, jsonify, request, abort
from flask_login import login_required, current_user
from models import db, Room, RoomFile, TrashedFile
from utils import user_has_permission, clean_path
import os
from datetime import datetime
trash_bp = Blueprint('trash', __name__)
@trash_bp.route('/<int:room_id>/trash', methods=['GET'])
@login_required
def list_trashed_files(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_view'):
abort(403)
# Get all trashed files in the room
files = TrashedFile.query.filter_by(room_id=room_id).order_by(TrashedFile.deleted_at.desc()).all()
result = []
for f in files:
uploader_full_name = None
uploader_profile_pic = None
if f.uploader:
uploader_full_name = f.uploader.username
if getattr(f.uploader, 'last_name', None):
uploader_full_name += ' ' + f.uploader.last_name
uploader_profile_pic = f.uploader.profile_picture if getattr(f.uploader, 'profile_picture', None) else None
deleter_full_name = None
if f.deleter:
deleter_full_name = f.deleter.username
if getattr(f.deleter, 'last_name', None):
deleter_full_name += ' ' + f.deleter.last_name
result.append({
'id': f.id,
'name': f.name,
'type': f.type,
'size': f.size if f.type == 'file' else '-',
'modified': f.modified,
'uploaded_by': uploader_full_name,
'uploader_profile_pic': uploader_profile_pic,
'uploaded_at': f.uploaded_at.isoformat() if f.uploaded_at else None,
'original_path': f.original_path,
'deleted_by': deleter_full_name,
'deleted_at': f.deleted_at.isoformat()
})
return jsonify(result)
@trash_bp.route('/<int:room_id>/trash/<int:trash_id>/restore', methods=['POST'])
@login_required
def restore_file(room_id, trash_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_upload'):
abort(403)
trashed_file = TrashedFile.query.get_or_404(trash_id)
if trashed_file.room_id != room_id:
abort(404)
# Create new RoomFile entry
rf = RoomFile(
room_id=room_id,
name=trashed_file.name,
path=trashed_file.original_path,
type=trashed_file.type,
size=trashed_file.size,
modified=trashed_file.modified,
uploaded_by=trashed_file.uploaded_by,
uploaded_at=trashed_file.uploaded_at
)
db.session.add(rf)
# Delete the trashed file entry
db.session.delete(trashed_file)
db.session.commit()
return jsonify({'success': True})
@trash_bp.route('/<int:room_id>/trash/<int:trash_id>', methods=['DELETE'])
@login_required
def permanently_delete_file(room_id, trash_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_delete'):
abort(403)
trashed_file = TrashedFile.query.get_or_404(trash_id)
if trashed_file.room_id != room_id:
abort(404)
# Delete the trashed file entry
db.session.delete(trashed_file)
db.session.commit()
return jsonify({'success': True})
@trash_bp.route('/<int:room_id>/trash/empty', methods=['POST'])
@login_required
def empty_trash(room_id):
room = Room.query.get_or_404(room_id)
if not user_has_permission(room, 'can_delete'):
abort(403)
# Delete all trashed files for this room
TrashedFile.query.filter_by(room_id=room_id).delete()
db.session.commit()
return jsonify({'success': True})

22
routes/user.py Normal file
View File

@@ -0,0 +1,22 @@
from flask import Blueprint, jsonify, request
from flask_login import login_required, current_user
from models import db
user_bp = Blueprint('user', __name__, url_prefix='/api/user')
@user_bp.route('/preferred_view', methods=['GET'])
@login_required
def get_preferred_view():
return jsonify({'preferred_view': current_user.preferred_view})
@user_bp.route('/preferred_view', methods=['POST'])
@login_required
def update_preferred_view():
data = request.get_json()
if not data or 'preferred_view' not in data:
return jsonify({'error': 'Missing preferred_view'}), 400
if data['preferred_view'] not in ['grid', 'list']:
return jsonify({'error': 'Invalid preferred_view'}), 400
current_user.preferred_view = data['preferred_view']
db.session.commit()
return jsonify({'preferred_view': current_user.preferred_view})

BIN
static/default-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

58
sync_room_files.py Normal file
View File

@@ -0,0 +1,58 @@
import os
from datetime import datetime
from app import create_app
from models import db, Room, RoomFile, User
DATA_ROOT = '/data/rooms'
app = create_app()
with app.app_context():
admin_user = User.query.filter_by(is_admin=True).first()
if not admin_user:
print('No admin user found. Aborting.')
exit(1)
rooms = Room.query.all()
for room in rooms:
room_dir = os.path.join(DATA_ROOT, str(room.id))
if not os.path.exists(room_dir):
continue
for root, dirs, files in os.walk(room_dir):
rel_root = os.path.relpath(root, room_dir)
rel_path = '' if rel_root == '.' else rel_root.replace('\\', '/')
# Folders
for d in dirs:
exists = RoomFile.query.filter_by(room_id=room.id, name=d, path=rel_path, type='folder').first()
folder_path = os.path.join(root, d)
stat = os.stat(folder_path)
if not exists:
rf = RoomFile(
room_id=room.id,
name=d,
path=rel_path,
type='folder',
size=None,
modified=stat.st_mtime,
uploaded_by=admin_user.id,
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
)
db.session.add(rf)
# Files
for f in files:
exists = RoomFile.query.filter_by(room_id=room.id, name=f, path=rel_path, type='file').first()
file_path = os.path.join(root, f)
stat = os.stat(file_path)
if not exists:
rf = RoomFile(
room_id=room.id,
name=f,
path=rel_path,
type='file',
size=stat.st_size,
modified=stat.st_mtime,
uploaded_by=admin_user.id,
uploaded_at=datetime.utcfromtimestamp(stat.st_mtime)
)
db.session.add(rf)
db.session.commit()
print('RoomFile table synchronized with filesystem.')

39
tasks.py Normal file
View File

@@ -0,0 +1,39 @@
from datetime import datetime, timedelta
from models import db, RoomFile
import os
def cleanup_trash():
"""
Permanently deletes files that have been in trash for more than 30 days.
This function should be called by a scheduler (e.g., cron job) daily.
"""
# Calculate the cutoff date (30 days ago)
cutoff_date = datetime.utcnow() - timedelta(days=30)
# Find all files that were deleted before the cutoff date
files_to_delete = RoomFile.query.filter(
RoomFile.deleted == True,
RoomFile.deleted_at <= cutoff_date
).all()
for file in files_to_delete:
try:
# Delete the file from storage if it's a file
if file.type == 'file':
file_path = os.path.join('/data/rooms', str(file.room_id), file.path, file.name)
if os.path.exists(file_path):
os.remove(file_path)
# Delete the database record
db.session.delete(file)
except Exception as e:
print(f"Error deleting file {file.name}: {str(e)}")
continue
# Commit all changes
try:
db.session.commit()
except Exception as e:
print(f"Error committing changes: {str(e)}")
db.session.rollback()

164
templates/base.html Normal file
View File

@@ -0,0 +1,164 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}DocuPulse{% endblock %}</title>
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
:root {
--primary-color: #16767b;
--secondary-color: #741b5f;
--primary-light: #1a8a90;
--secondary-light: #8a2170;
}
body {
background-color: #f8f9fa;
}
.navbar {
background-color: var(--primary-color) !important;
}
.sidebar {
background-color: white;
min-height: calc(100vh - 56px);
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
}
.sidebar .nav-link {
color: #333;
padding: 0.8rem 1rem;
border-radius: 0.25rem;
margin: 0.2rem 0;
}
.sidebar .nav-link:hover {
background-color: #f8f9fa;
}
.sidebar .nav-link.active {
background-color: var(--primary-color);
color: white;
}
.sidebar .nav-link i {
margin-right: 0.5rem;
}
.main-content {
padding: 2rem;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
.document-card {
transition: transform 0.2s;
}
.document-card:hover {
transform: translateY(-5px);
}
</style>
{% block extra_css %}{% endblock %}
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for('main.dashboard') }}">DocuPulse</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link flex items-center justify-center" href="#">
<i class="fas fa-bell text-xl" style="width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center;"></i>
</a>
</li>
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle flex items-center gap-2" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown">
<img src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="Profile Picture"
class="w-8 h-8 rounded-full object-cover border-2 border-white shadow"
style="display: inline-block; vertical-align: middle;">
<span class="text-white font-medium">{{ current_user.username }}</span>
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('main.profile') }}"><i class="fas fa-user"></i> Profile</a></li>
<li><a class="dropdown-item" href="#"><i class="fas fa-cog"></i> Settings</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}"><i class="fas fa-sign-out-alt"></i> Logout</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
<div class="container-fluid">
<div class="row">
<!-- Sidebar -->
<div class="col-md-3 col-lg-2 px-0 sidebar">
<div class="p-3">
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}" href="{{ url_for('main.dashboard') }}">
<i class="fas fa-home"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'rooms.rooms' %}active{% endif %}" href="{{ url_for('rooms.rooms') }}">
<i class="fas fa-door-open"></i> Rooms
</a>
</li>
<li class="nav-item">
{% if current_user.is_admin %}
<a class="nav-link {% if request.endpoint == 'contacts.contacts_list' %}active{% endif %}" href="{{ url_for('contacts.contacts_list') }}">
<i class="fas fa-address-book"></i> Contacts
</a>
{% endif %}
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.starred' %}active{% endif %}" href="{{ url_for('main.starred') }}">
<i class="fas fa-star"></i> Starred
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.trash' %}active{% endif %}" href="{{ url_for('main.trash') }}">
<i class="fas fa-trash"></i> Trash
</a>
</li>
</ul>
</div>
</div>
<!-- Main Content -->
<div class="col-md-9 col-lg-10 main-content">
{% block content %}{% endblock %}
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
{% block extra_js %}{% endblock %}
</body>
</html>

8
templates/contacts.html Normal file
View File

@@ -0,0 +1,8 @@
{% extends "base.html" %}
{% block title %}Contacts - DocuPulse{% endblock %}
{% block content %}
<h2>Contacts</h2>
<p class="text-muted">Your contacts will appear here.</p>
{% endblock %}

View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-2xl mx-auto">
<div class="flex justify-between items-center mb-6">
{% if title %}
<h1 class="text-2xl font-bold text-gray-800 mb-6">{{ title }}</h1>
{% else %}
<h1 class="text-2xl font-bold text-gray-800 mb-6">User Form</h1>
{% endif %}
<a href="{{ url_for('contacts.contacts_list') }}"
class="text-gray-600 hover:text-gray-900">
← Back to Contacts
</a>
</div>
<div class="bg-white rounded-lg shadow p-6">
<form method="POST" class="space-y-6" enctype="multipart/form-data">
{{ form.hidden_tag() }}
<!-- Profile Picture Upload (matches profile page style) -->
<div class="flex flex-col items-center mb-6">
<div class="relative group flex flex-col items-center">
<label for="profile_picture" class="cursor-pointer">
<img id="avatarPreview" src="{{ url_for('profile_pic', filename=form.profile_picture.data or user.profile_picture) if (form.profile_picture.data or (user and user.profile_picture)) else url_for('static', filename='default-avatar.png') }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-gray-200 mb-0 transition duration-200 group-hover:opacity-80 group-hover:ring-4 group-hover:ring-primary-200 shadow-sm">
<input id="profile_picture" type="file" name="profile_picture" accept="image/*" class="hidden" onchange="previewAvatar(event)" />
</label>
{% if user and user.profile_picture %}
<button type="submit" name="remove_picture" value="1" class="mt-2 mb-2 text-xs px-3 py-1 rounded bg-red-100 text-red-700 border border-red-200 hover:bg-red-200 transition">Remove Picture</button>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form.first_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.first_name(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.first_name.errors %}
{% for error in form.first_name.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
<div>
{{ form.last_name.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.last_name(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.last_name.errors %}
{% for error in form.last_name.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
</div>
<div>
{{ form.email.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.email(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.email.errors %}
{% for error in form.email.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
{% if current_user.is_admin %}
<div class="relative">
{{ form.new_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.new_password(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10", autocomplete="new-password", id="new_password") }}
<button type="button" tabindex="-1" class="absolute right-2 top-9 text-gray-500" style="background: none; border: none;" onmousedown="showPwd('new_password')" onmouseup="hidePwd('new_password')" onmouseleave="hidePwd('new_password')">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="relative">
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.confirm_password(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 pr-10", autocomplete="new-password", id="confirm_password") }}
<button type="button" tabindex="-1" class="absolute right-2 top-9 text-gray-500" style="background: none; border: none;" onmousedown="showPwd('confirm_password')" onmouseup="hidePwd('confirm_password')" onmouseleave="hidePwd('confirm_password')">
<i class="fas fa-eye"></i>
</button>
{% if form.confirm_password.errors %}
{% for error in form.confirm_password.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
<script>
function showPwd(id) {
document.getElementById(id).type = 'text';
}
function hidePwd(id) {
document.getElementById(id).type = 'password';
}
</script>
{% endif %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
{{ form.phone.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.phone(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.phone.errors %}
{% for error in form.phone.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
<div>
{{ form.company.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.company(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.company.errors %}
{% for error in form.company.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
</div>
<div>
{{ form.position.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.position(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500") }}
{% if form.position.errors %}
{% for error in form.position.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
<div>
{{ form.notes.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.notes(class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500", rows="4") }}
{% if form.notes.errors %}
{% for error in form.notes.errors %}
<p class="mt-1 text-sm text-red-600">{{ error }}</p>
{% endfor %}
{% endif %}
</div>
<div class="flex flex-col space-y-4">
<div class="flex items-center space-x-4">
<div class="flex items-center">
{{ form.is_active(class="h-4 w-4 focus:ring-blue-500 border-gray-300 rounded", style="accent-color: #16767b;") }}
{{ form.is_active.label(class="ml-2 block text-sm text-gray-900") }}
</div>
<div class="flex items-center relative group">
{% set is_last_admin = current_user.is_admin and total_admins <= 1 %}
{{ form.is_admin(
class="h-4 w-4 focus:ring-blue-500 border-gray-300 rounded",
style="accent-color: #16767b;",
disabled=is_last_admin and form.is_admin.data
) }}
{{ form.is_admin.label(class="ml-2 block text-sm text-gray-900") }}
{% if is_last_admin and form.is_admin.data %}
<input type="hidden" name="is_admin" value="y">
{% endif %}
<div class="absolute left-0 bottom-full mb-2 hidden group-hover:block bg-gray-800 text-white text-xs rounded py-1 px-2 w-48">
Admin users have full access to manage contacts and system settings.
</div>
{% if is_last_admin and form.is_admin.data %}
<div class="ml-2 text-sm text-amber-600">
<i class="fas fa-exclamation-circle"></i>
You are the only admin
</div>
{% endif %}
</div>
</div>
{% if form.is_admin.errors %}
<div class="p-3 bg-red-50 border border-red-200 rounded-lg">
{% for error in form.is_admin.errors %}
<p class="text-sm text-red-700">{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div class="flex justify-end">
{{ form.submit(class="text-white px-6 py-2 rounded-lg transition duration-200", style="background-color: #16767b; border: 1px solid #16767b;", onmouseover="this.style.backgroundColor='#1a8a90'", onmouseout="this.style.backgroundColor='#16767b'") }}
</div>
</form>
</div>
</div>
</div>
<script>
function previewAvatar(event) {
const [file] = event.target.files;
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('avatarPreview').src = e.target.result;
};
reader.readAsDataURL(file);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,247 @@
{% extends "base.html" %}
{% block head %}
{{ super() }}
<style>
body {
background: #f7f9fb;
}
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">Contacts</h1>
<a href="{{ url_for('contacts.new_contact') }}"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-white text-sm font-semibold transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg no-underline"
style="background-color: #16767b; border: 1px solid #16767b;"
onmouseover="this.style.backgroundColor='#1a8a90'"
onmouseout="this.style.backgroundColor='#16767b'">
<i class="fas fa-plus"></i>
Add New Contact
</a>
</div>
<!-- Search and Filter Section -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-6">
<form method="GET" class="flex flex-col md:flex-row gap-4" id="filterForm">
<div class="flex-1">
<input type="text" name="search" placeholder="Search contacts..."
value="{{ request.args.get('search', '') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
</div>
<div class="flex gap-4 items-center">
<select name="status" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
<option value="">All Status</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
<option value="inactive" {% if request.args.get('status') == 'inactive' %}selected{% endif %}>Inactive</option>
</select>
<select name="role" class="px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-200">
<option value="">All Roles</option>
<option value="admin" {% if request.args.get('role') == 'admin' %}selected{% endif %}>Admin</option>
<option value="user" {% if request.args.get('role') == 'user' %}selected{% endif %}>User</option>
</select>
<button type="button" id="clearFilters" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200"
style="background-color: #16767b; border: 1px solid #16767b;"
onmouseover="this.style.backgroundColor='#1a8a90'"
onmouseout="this.style.backgroundColor='#16767b'">
Clear
</button>
</div>
</form>
<script>
// Debounce function
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Auto-submit the form on select change
document.querySelectorAll('#filterForm select').forEach(function(el) {
el.addEventListener('change', function() {
document.getElementById('filterForm').submit();
});
});
// Debounced submit for search input, keep cursor after reload
const searchInput = document.querySelector('#filterForm input[name="search"]');
if (searchInput) {
searchInput.addEventListener('input', debounce(function() {
// Save value and cursor position
sessionStorage.setItem('searchFocus', '1');
sessionStorage.setItem('searchValue', searchInput.value);
sessionStorage.setItem('searchPos', searchInput.selectionStart);
document.getElementById('filterForm').submit();
}, 300));
// On page load, restore focus and cursor position if needed
window.addEventListener('DOMContentLoaded', function() {
if (sessionStorage.getItem('searchFocus') === '1') {
searchInput.focus();
const val = sessionStorage.getItem('searchValue') || '';
const pos = parseInt(sessionStorage.getItem('searchPos')) || val.length;
searchInput.value = val;
searchInput.setSelectionRange(pos, pos);
// Clean up
sessionStorage.removeItem('searchFocus');
sessionStorage.removeItem('searchValue');
sessionStorage.removeItem('searchPos');
}
});
}
// Clear button resets all filters and submits the form
document.getElementById('clearFilters').addEventListener('click', function() {
document.querySelector('#filterForm input[name="search"]').value = '';
document.querySelector('#filterForm select[name="status"]').selectedIndex = 0;
document.querySelector('#filterForm select[name="role"]').selectedIndex = 0;
document.getElementById('filterForm').submit();
});
</script>
</div>
<!-- Contacts List -->
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Contact Info</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Role</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for user in users %}
<tr class="hover:bg-gray-50">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<div class="flex-shrink-0 h-10 w-10">
<img class="h-10 w-10 rounded-full object-cover"
src="{{ url_for('profile_pic', filename=user.profile_picture) if user.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="{{ user.username }}">
</div>
<div class="ml-4">
<div class="text-sm font-medium text-gray-900">{{ user.username }} {{ user.last_name }}</div>
<div class="text-sm text-gray-500">{{ user.position or 'No position' }}</div>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-row flex-wrap gap-1.5">
<a href="mailto:{{ user.email }}"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
<i class="fas fa-envelope" style="font-size: 0.85em; opacity: 0.7;"></i>
{{ user.email }}
</a>
{% if user.phone %}
<a href="tel:{{ user.phone }}"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
<i class="fas fa-phone" style="font-size: 0.85em; opacity: 0.7;"></i>
{{ user.phone }}
</a>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-900">{{ user.company or 'No company' }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-row flex-wrap gap-1.5">
{% if user.email != current_user.email and not user.is_admin %}
<form method="POST" action="{{ url_for('contacts.toggle_active', id=user.id) }}" class="inline">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium transition-colors duration-200 cursor-pointer"
style="background-color: {% if user.is_active %}rgba(34,197,94,0.1){% else %}rgba(239,68,68,0.1){% endif %}; color: {% if user.is_active %}#15803d{% else %}#b91c1c{% endif %};">
<i class="fas fa-{% if user.is_active %}check-circle{% else %}times-circle{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
{{ 'Active' if user.is_active else 'Inactive' }}
</button>
</form>
{% else %}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium"
style="background-color: {% if user.is_active %}rgba(34,197,94,0.1){% else %}rgba(239,68,68,0.1){% endif %}; color: {% if user.is_active %}#15803d{% else %}#b91c1c{% endif %};">
<i class="fas fa-{% if user.is_active %}check-circle{% else %}times-circle{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
{{ 'Active' if user.is_active else 'Inactive' }}
</span>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex flex-row flex-wrap gap-1.5">
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm font-medium"
style="background-color: {% if user.is_admin %}rgba(147,51,234,0.1){% else %}rgba(107,114,128,0.1){% endif %}; color: {% if user.is_admin %}#7e22ce{% else %}#374151{% endif %};">
<i class="fas fa-{% if user.is_admin %}shield-alt{% else %}user{% endif %}" style="font-size: 0.85em; opacity: 0.7;"></i>
{{ 'Admin' if user.is_admin else 'User' }}
</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex justify-end gap-1.5">
<a href="{{ url_for('contacts.edit_contact', id=user.id) }}"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
<i class="fas fa-edit" style="font-size: 0.85em; opacity: 0.7;"></i>
Edit
</a>
{% if user.email != current_user.email %}
<form method="POST" action="{{ url_for('contacts.delete_contact', id=user.id) }}" class="inline"
onsubmit="return confirm('Are you sure you want to delete this contact?');">
<button type="submit"
class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-sm no-underline transition-colors duration-200"
style="background-color: rgba(239,68,68,0.1); color: #b91c1c;">
<i class="fas fa-trash" style="font-size: 0.85em; opacity: 0.7;"></i>
Delete
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
{% if pagination and pagination.pages > 1 %}
<div class="mt-6 flex justify-center">
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
{% if pagination.has_prev %}
<a href="{{ url_for('contacts.contacts_list', page=pagination.prev_num, **request.args) }}"
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
<span class="sr-only">Previous</span>
<i class="fas fa-chevron-left"></i>
</a>
{% endif %}
{% for page in pagination.iter_pages() %}
{% if page %}
<a href="{{ url_for('contacts.contacts_list', page=page, **request.args) }}"
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium {% if page == pagination.page %}text-[#16767b] bg-[#16767b]/10{% else %}text-gray-700 hover:bg-gray-50{% endif %}">
{{ page }}
</a>
{% else %}
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700">
...
</span>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<a href="{{ url_for('contacts.contacts_list', page=pagination.next_num, **request.args) }}"
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
<span class="sr-only">Next</span>
<i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</nav>
</div>
{% endif %}
</div>
{% endblock %}

View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Create Room - DocuPulse{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2 class="card-title mb-0">Create New Room</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('rooms.create_room') }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<div class="text-danger">
{% for error in form.name.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
{% if form.description.errors %}
<div class="text-danger">
{% for error in form.description.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-secondary">Cancel</a>
{{ form.submit(class="btn btn-primary") }}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

471
templates/dashboard.html Normal file
View File

@@ -0,0 +1,471 @@
{% extends "base.html" %}
{% block title %}Dashboard - DocuPulse{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Welcome back, {{ current_user.username }}!</h2>
<div class="input-group" style="max-width: 300px;">
<input type="text" class="form-control" placeholder="Search documents...">
<button class="btn btn-outline-secondary" type="button">
<i class="fas fa-search"></i>
</button>
</div>
</div>
{% macro format_size(size) %}
{% if size < 1024 %}
{{ size }} B
{% elif size < 1024 * 1024 %}
{{ (size / 1024)|round(1) }} KB
{% elif size < 1024 * 1024 * 1024 %}
{{ (size / (1024 * 1024))|round(1) }} MB
{% else %}
{{ (size / (1024 * 1024 * 1024))|round(1) }} GB
{% endif %}
{% endmacro %}
<style>
.masonry {
column-count: 1;
column-gap: 1.5rem;
}
@media (min-width: 768px) {
.masonry { column-count: 2; }
}
@media (min-width: 1200px) {
.masonry { column-count: 3; }
}
.masonry-card {
display: inline-block;
width: 100%;
margin-bottom: 1.5rem;
}
</style>
<div class="masonry">
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-database me-2" style="color:#16767b;"></i>Storage Overview</h5>
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">Browse</a>
</div>
<div class="d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-door-open me-2" style="color:#16767b;"></i>
<span class="text-muted">Rooms:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ room_count }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-file me-2" style="color:#16767b;"></i>
<span class="text-muted">Files:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ file_count }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-folder me-2" style="color:#16767b;"></i>
<span class="text-muted">Folders:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ folder_count }}</div>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<i class="fas fa-hdd me-2" style="color:#16767b;"></i>
<span class="text-muted">Total Size:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ format_size(total_size) }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="masonry-card">
<div class="card h-100">
<div class="card-header bg-white border-0">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-history me-2" style="color:#16767b;"></i>Recent Activity</h5>
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
</div>
</div>
<div class="card-body">
<div class="list-group list-group-flush">
{% for activity in recent_activity %}
<div class="list-group-item px-0">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
{% if activity.type == 'folder' %}
<i class="fas fa-folder me-2" style="color:#16767b;"></i>
{% else %}
<i class="fas fa-file me-2" style="color:#16767b;"></i>
{% endif %}
</div>
<div class="flex-grow-1 ms-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<div class="d-flex align-items-center mb-1">
<h6 class="mb-0">{{ activity.name }}</h6>
{% if activity.is_starred %}
<span class="badge bg-warning text-dark ms-2" style="background-color: rgba(255,215,0,0.15) !important; color: #ffd700 !important;">
<i class="fas fa-star me-1"></i>Starred
</span>
{% endif %}
{% if activity.is_deleted %}
<span class="badge bg-danger ms-2" style="background-color: rgba(220,53,69,0.15) !important; color: #dc3545 !important;">
<i class="fas fa-trash me-1"></i>Trash
</span>
{% endif %}
</div>
<small class="text-muted">
{{ activity.room.name }} •
{{ activity.uploader.username }} {{ activity.uploader.last_name }} •
{% if activity.uploaded_at %}{{ activity.uploaded_at|timeago }}{% else %}Unknown{% endif %}
</small>
</div>
{% if activity.type == 'file' and activity.can_download %}
<a href="{{ url_for('room_files.download_room_file', room_id=activity.room.id, filename=activity.name, path=activity.path) }}"
class="btn btn-sm text-white ms-2" style="background-color:#16767b; border-radius:6px; border:none; box-shadow:0 1px 2px rgba(22,118,123,0.08);" title="Download">
<i class="fas fa-download"></i>
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
</div>
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-chart-pie me-2" style="color:#16767b;"></i>Storage Usage</h5>
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View Details</a>
</div>
{% if storage_by_type %}
<div class="position-relative" style="height: 200px;">
<canvas id="storageChart"></canvas>
</div>
<div class="mt-3">
{% for type in storage_by_type %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<i class="fas fa-file me-2" style="color:#16767b;"></i>
<span class="text-muted">{{ type.extension|upper }}:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ format_size(type.total_size) }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small">No storage data available</div>
{% endif %}
</div>
</div>
</div>
{% if current_user.is_admin %}
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-address-book me-2" style="color:#16767b;"></i>Recent Contacts</h5>
<div>
<a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-sm text-white me-2" style="background-color:#16767b;">View All</a>
<a href="{{ url_for('contacts.new_contact') }}" class="btn btn-sm text-white" style="background-color:#16767b;">+ Add</a>
</div>
</div>
{% if recent_contacts %}
<ul class="list-unstyled mb-3">
{% for contact in recent_contacts %}
<li class="mb-2">
<div class="fw-semibold">{{ contact.first_name }} {{ contact.last_name }}</div>
<div class="flex flex-wrap gap-2 mt-1">
<a href="mailto:{{ contact.email }}"
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-normal transition duration-200 no-underline"
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
<i class="fas fa-envelope mr-1" style="font-size: 0.85em; opacity: 0.7;"></i>{{ contact.email }}
</a>
{% if contact.phone %}
<a href="tel:{{ contact.phone }}"
class="inline-flex items-center px-2 py-0.5 rounded text-sm font-normal transition duration-200 no-underline"
style="background-color: rgba(22,118,123,0.08); color: #16767b;">
<i class="fas fa-phone mr-1" style="font-size: 0.85em; opacity: 0.7;"></i>{{ contact.phone }}
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% else %}
<div class="text-muted small">No contacts yet.</div>
{% endif %}
</div>
</div>
</div>
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-chart-pie me-2" style="color:#16767b;"></i>Contact Status</h5>
<div>
<a href="{{ url_for('contacts.contacts_list') }}" class="btn btn-sm text-white me-2" style="background-color:#16767b;">View All</a>
<a href="{{ url_for('contacts.new_contact') }}" class="btn btn-sm text-white" style="background-color:#16767b;">+ Add</a>
</div>
</div>
<div class="position-relative" style="height: 200px;">
<canvas id="statusChart"></canvas>
</div>
<div class="d-flex justify-content-center gap-4 mt-3">
<div class="text-center">
<div class="fw-bold" style="color:#16767b; font-size:1.5rem;">{{ active_count }}</div>
<div class="text-muted small">Active</div>
</div>
<div class="text-center">
<div class="fw-bold" style="color:#741b5f; font-size:1.5rem;">{{ inactive_count }}</div>
<div class="text-muted small">Inactive</div>
</div>
</div>
</div>
</div>
</div>
{% endif %}
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-star me-2" style="color:#16767b;"></i>Starred Files</h5>
<a href="{{ url_for('main.starred') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
</div>
<div class="position-relative" style="height: 200px;">
<canvas id="starredChart"></canvas>
</div>
<div class="d-flex justify-content-center gap-4 mt-3">
<div class="text-center">
<div class="fw-bold" style="color:#ffd700; font-size:1.5rem;">{{ starred_count }}</div>
<div class="text-muted small">Starred</div>
</div>
<div class="text-center">
<div class="fw-bold" style="color:#16767b; font-size:1.5rem;">{{ file_count - starred_count }}</div>
<div class="text-muted small">Unstarred</div>
</div>
</div>
</div>
</div>
</div>
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-trash me-2" style="color:#16767b;"></i>Trash</h5>
<a href="{{ url_for('main.trash') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
</div>
<div class="d-flex flex-column">
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-file me-2" style="color:#16767b;"></i>
<span class="text-muted">Files in trash:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ trash_count }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-exclamation-triangle me-2" style="color:#dc3545;"></i>
<span class="text-muted">Deleting in 7 days:</span>
</div>
<div class="fw-bold" style="color:#dc3545;">{{ pending_deletion }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-clock me-2" style="color:#16767b;"></i>
<span class="text-muted">Oldest deletion:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ oldest_trash_date|default('N/A') }}</div>
</div>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<i class="fas fa-hdd me-2" style="color:#16767b;"></i>
<span class="text-muted">Storage used:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ format_size(trash_size) }}</div>
</div>
<div class="alert alert-info mb-0">
<div class="d-flex align-items-center">
<i class="fas fa-info-circle me-2"></i>
<div>
<div class="fw-bold mb-1">Files will be permanently deleted after 30 days</div>
<div class="small">You can restore files before they are permanently deleted</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="masonry-card">
<div class="card shadow-sm">
<div class="card-body">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="card-title mb-0"><i class="fas fa-file-alt me-2" style="color:#16767b;"></i>Trash by Type</h5>
<a href="{{ url_for('main.trash') }}" class="btn btn-sm text-white" style="background-color:#16767b;">View All</a>
</div>
{% if trash_by_type %}
<div class="position-relative" style="height: 200px;">
<canvas id="trashTypeChart"></canvas>
</div>
<div class="mt-3">
{% for type in trash_by_type %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="d-flex align-items-center">
<i class="fas fa-file me-2" style="color:#16767b;"></i>
<span class="text-muted">{{ type.extension|upper }}:</span>
</div>
<div class="fw-bold" style="color:#16767b;">{{ type.count }}</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-muted small">No files in trash</div>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Contact Status Chart
const statusCtx = document.getElementById('statusChart');
if (statusCtx) {
new Chart(statusCtx, {
type: 'doughnut',
data: {
labels: ['Active', 'Inactive'],
datasets: [{
data: [{{ active_count }}, {{ inactive_count }}],
backgroundColor: ['#16767b', '#741b5f'],
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
cutout: '70%'
}
});
}
// Starred Files Chart
const starredCtx = document.getElementById('starredChart');
if (starredCtx) {
new Chart(starredCtx, {
type: 'doughnut',
data: {
labels: ['Starred', 'Unstarred'],
datasets: [{
data: [{{ starred_count }}, {{ file_count - starred_count }}],
backgroundColor: ['#ffd700', '#16767b'],
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
cutout: '70%'
}
});
}
// Storage Usage Chart
const storageCtx = document.getElementById('storageChart');
if (storageCtx) {
const storageData = {
labels: [{% for type in storage_by_type %}'{{ type.extension|upper }}'{% if not loop.last %}, {% endif %}{% endfor %}],
datasets: [{
data: [{% for type in storage_by_type %}{{ type.total_size }}{% if not loop.last %}, {% endif %}{% endfor %}],
backgroundColor: [
'#16767b', '#2c9da9', '#43c4d3', '#5ad9e8', '#71eefd',
'#741b5f', '#8a2b73', '#a03b87', '#b64b9b', '#cc5baf'
],
borderWidth: 0,
hoverOffset: 4
}]
};
new Chart(storageCtx, {
type: 'doughnut',
data: storageData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
cutout: '70%'
}
});
}
// Trash Type Chart
const trashTypeCtx = document.getElementById('trashTypeChart');
if (trashTypeCtx) {
const trashTypeData = {
labels: [{% for type in trash_by_type %}'{{ type.extension|upper }}'{% if not loop.last %}, {% endif %}{% endfor %}],
datasets: [{
data: [{% for type in trash_by_type %}{{ type.count }}{% if not loop.last %}, {% endif %}{% endfor %}],
backgroundColor: [
'#16767b', '#2c9da9', '#43c4d3', '#5ad9e8', '#71eefd',
'#741b5f', '#8a2b73', '#a03b87', '#b64b9b', '#cc5baf'
],
borderWidth: 0,
hoverOffset: 4
}]
};
new Chart(trashTypeCtx, {
type: 'doughnut',
data: trashTypeData,
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
cutout: '70%'
}
});
}
});
</script>
{% endblock %}

48
templates/edit_room.html Normal file
View File

@@ -0,0 +1,48 @@
{% extends "base.html" %}
{% block title %}Edit Room - {{ room.name }}{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h2 class="card-title mb-0">Edit Room</h2>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('rooms.edit_room', room_id=room.id) }}">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.name.label(class="form-label") }}
{{ form.name(class="form-control") }}
{% if form.name.errors %}
<div class="text-danger">
{% for error in form.name.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="mb-3">
<label for="{{ form.description.id }}" class="form-label">Description (Optional)</label>
{{ form.description(class="form-control", rows="3", placeholder="Enter a description (optional)") }}
{% if form.description.errors %}
<div class="text-danger">
{% for error in form.description.errors %}
<small>{{ error }}</small>
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('rooms.rooms') }}" class="btn btn-secondary">Cancel</a>
{{ form.submit(class="btn btn-primary", value="Update Room") }}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

148
templates/home.html Normal file
View File

@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DocuPulse - Legal Document Management</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
:root {
--primary-color: #16767b;
--secondary-color: #741b5f;
--primary-light: #1a8a90;
--secondary-light: #8a2170;
}
.navbar {
background-color: var(--primary-color) !important;
}
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
padding: 100px 0;
}
.feature-card {
border: none;
border-radius: 10px;
transition: transform 0.3s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.feature-card:hover {
transform: translateY(-5px);
}
.feature-icon {
font-size: 2.5rem;
color: var(--primary-color);
margin-bottom: 1rem;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
.btn-outline-primary {
color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-outline-primary:hover {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
footer {
background-color: var(--primary-color) !important;
}
.nav-link:hover {
color: var(--secondary-color) !important;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark">
<div class="container">
<a class="navbar-brand" href="/">DocuPulse</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
</li>
</ul>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="hero-section">
<div class="container text-center">
<h1 class="display-4 mb-4">Streamline Your Legal Document Management</h1>
<p class="lead mb-5">Secure, efficient, and intelligent document handling for modern law practices</p>
<a href="{{ url_for('auth.register') }}" class="btn btn-light btn-lg">Get Started</a>
</div>
</section>
<!-- Features Section -->
<section class="py-5">
<div class="container">
<h2 class="text-center mb-5">Key Features</h2>
<div class="row g-4">
<div class="col-md-4">
<div class="card feature-card h-100 p-4">
<div class="card-body text-center">
<i class="fas fa-shield-alt feature-icon"></i>
<h3 class="h5">Secure Storage</h3>
<p class="text-muted">Bank-level encryption and secure cloud storage for your sensitive legal documents</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card feature-card h-100 p-4">
<div class="card-body text-center">
<i class="fas fa-search feature-icon"></i>
<h3 class="h5">Smart Search</h3>
<p class="text-muted">Advanced search capabilities to find any document in seconds</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card feature-card h-100 p-4">
<div class="card-body text-center">
<i class="fas fa-users feature-icon"></i>
<h3 class="h5">Collaboration</h3>
<p class="text-muted">Seamless collaboration tools for legal teams and clients</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="text-light py-4">
<div class="container text-center">
<p class="mb-0">&copy; 2024 DocuPulse. All rights reserved.</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

12
templates/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Flask App</title>
</head>
<body>
<h1>Welcome to Flask App</h1>
<p>Your application is running successfully!</p>
</body>
</html>

105
templates/login.html Normal file
View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - DocuPulse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
:root {
--primary-color: #16767b;
--secondary-color: #741b5f;
--primary-light: #1a8a90;
--secondary-light: #8a2170;
}
body {
background-color: #f8f9fa;
}
.login-container {
max-width: 400px;
margin: 100px auto;
}
.login-card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.login-header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
padding: 20px;
border-radius: 10px 10px 0 0;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(22, 118, 123, 0.25);
}
.alert {
border-radius: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="login-container">
<div class="card login-card">
<div class="login-header text-center">
<h2>Welcome Back</h2>
<p class="mb-0">Sign in to your account</p>
</div>
<div class="card-body p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% if category in ['error', 'danger', 'login'] %}
<div class="alert alert-{{ 'danger' if category in ['error', 'danger'] else 'info' }}">{{ message }}</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.login') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" id="remember" name="remember">
<label class="form-check-label" for="remember">Remember me</label>
</div>
<button type="submit" class="btn btn-primary w-100">Sign In</button>
</form>
<div class="text-center mt-3">
<p class="mb-0">Don't have an account? <a href="{{ url_for('auth.register') }}" class="text-decoration-none">Register</a></p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

115
templates/profile.html Normal file
View File

@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% block title %}Profile - DocuPulse{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="max-w-3xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">My Profile</h1>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="mb-4 p-4 rounded-lg {% if category == 'error' %}bg-red-100 text-red-700{% else %}bg-green-100 text-green-700{% endif %}">
{{ message }}
</div>
{% endfor %}
{% endif %}
{% endwith %}
<div class="bg-white rounded-lg shadow overflow-hidden">
<form method="POST" action="{{ url_for('main.profile') }}" class="p-6" enctype="multipart/form-data">
<div class="flex flex-col items-center mb-6">
<div class="relative group flex flex-col items-center">
<label for="profile_picture" class="cursor-pointer">
<img id="avatarPreview" src="{{ url_for('profile_pic', filename=current_user.profile_picture) if current_user.profile_picture else url_for('static', filename='default-avatar.png') }}" alt="Profile Picture" class="w-32 h-32 rounded-full object-cover border-4 border-gray-200 mb-0 transition duration-200 group-hover:opacity-80 group-hover:ring-4 group-hover:ring-primary-200 shadow-sm">
<input id="profile_picture" type="file" name="profile_picture" accept="image/*" class="hidden" onchange="previewAvatar(event)" />
</label>
{% if current_user.profile_picture %}
<button type="submit" name="remove_picture" value="1" class="mt-2 text-xs px-3 py-1 rounded bg-red-100 text-red-700 border border-red-200 hover:bg-red-200 transition">Remove Picture</button>
{% endif %}
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">First Name</label>
<input type="text" name="first_name" value="{{ current_user.username }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Last Name</label>
<input type="text" name="last_name" value="{{ current_user.last_name }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" name="email" value="{{ current_user.email }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input type="tel" name="phone" value="{{ current_user.phone or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Company</label>
<input type="text" name="company" value="{{ current_user.company or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Position</label>
<input type="text" name="position" value="{{ current_user.position or '' }}"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div class="mt-6">
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea name="notes" rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">{{ current_user.notes or '' }}</textarea>
</div>
<div class="mt-6">
<h3 class="text-lg font-medium text-gray-900 mb-4">Change Password</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">New Password</label>
<input type="password" name="new_password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Confirm New Password</label>
<input type="password" name="confirm_password"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<div class="mt-8 flex justify-end">
<button type="submit"
class="text-white px-6 py-2 rounded-lg transition duration-200"
style="background-color: #16767b; border: 1px solid #16767b;"
onmouseover="this.style.backgroundColor='#1a8a90'"
onmouseout="this.style.backgroundColor='#16767b'">
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<script>
function previewAvatar(event) {
const [file] = event.target.files;
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('avatarPreview').src = e.target.result;
};
reader.readAsDataURL(file);
}
}
</script>
{% endblock %}

102
templates/register.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Register - DocuPulse</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
:root {
--primary-color: #16767b;
--secondary-color: #741b5f;
--primary-light: #1a8a90;
--secondary-light: #8a2170;
}
body {
background-color: #f8f9fa;
}
.register-container {
max-width: 400px;
margin: 100px auto;
}
.register-card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.register-header {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%);
color: white;
padding: 20px;
border-radius: 10px 10px 0 0;
}
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-primary:hover {
background-color: var(--primary-light);
border-color: var(--primary-light);
}
.form-control:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(22, 118, 123, 0.25);
}
.alert {
border-radius: 10px;
}
</style>
</head>
<body>
<div class="container">
<div class="register-container">
<div class="card register-card">
<div class="register-header text-center">
<h2>Create Account</h2>
<p class="mb-0">Join DocuPulse today</p>
</div>
<div class="card-body p-4">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="POST" action="{{ url_for('auth.register') }}">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="email" class="form-label">Email address</label>
<input type="email" class="form-control" id="email" name="email" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<button type="submit" class="btn btn-primary w-100">Create Account</button>
</form>
<div class="text-center mt-3">
<p class="mb-0">Already have an account? <a href="{{ url_for('auth.login') }}" class="text-decoration-none">Sign In</a></p>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

1819
templates/room.html Normal file

File diff suppressed because it is too large Load Diff

223
templates/room_members.html Normal file
View File

@@ -0,0 +1,223 @@
{% extends "base.html" %}
{% block title %}Room Members - {{ room.name }}{% endblock %}
{% block extra_css %}
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<style>
.badge.bg-primary.rounded-pill {
display: inline-flex;
align-items: center;
font-weight: 500;
letter-spacing: 0.02em;
box-shadow: 0 1px 2px rgba(0,0,0,0.04);
font-size: 1em;
height: 32px;
border-radius: 5px;
padding: 0 18px;
}
.btn-sm.member-action {
min-width: 70px;
height: 32px;
font-size: 1em;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
}
.form-check-inline {
margin-right: 8px;
}
.member-row {
align-items: center;
gap: 8px;
}
.badge.creator-badge {
display: inline-flex;
align-items: center;
font-weight: 500;
letter-spacing: 0.02em;
font-size: 1em;
height: 32px;
border-radius: 5px;
padding: 0 18px;
background-color: rgba(22,118,123,0.08);
color: #16767b;
border: 1px solid #16767b22;
}
.btn-save-member {
background-color: #16767b;
color: #fff;
border: 1px solid #16767b;
min-width: 70px;
height: 32px;
font-size: 1em;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
transition: background 0.2s, border 0.2s;
}
.btn-save-member:hover {
background-color: #1a8a90;
border-color: #1a8a90;
}
.btn-remove-member {
background-color: rgba(239,68,68,0.1);
color: #b91c1c;
border: 1px solid #b91c1c22;
min-width: 70px;
height: 32px;
font-size: 1em;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 16px;
transition: background 0.2s, color 0.2s;
}
.btn-remove-member:hover {
background-color: #b91c1c;
color: #fff;
}
</style>
{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row mb-4">
<div class="col">
<h2>Room Members - {{ room.name }}</h2>
<p class="text-muted">Manage who has access to this room.</p>
</div>
</div>
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="card-title mb-0">Current Members</h5>
</div>
<div class="card-body">
{% if room.member_permissions %}
<div class="list-group">
{% for perm in room.member_permissions %}
{% set member = perm.user %}
<div class="list-group-item d-flex justify-content-between align-items-center member-row">
<div class="d-flex align-items-center">
<img src="{{ url_for('profile_pic', filename=member.profile_picture) if member.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="{{ member.username }}"
class="rounded-circle me-3"
style="width: 40px; height: 40px; object-fit: cover;">
<div>
<h6 class="mb-0">{{ member.username }} {{ member.last_name }}</h6>
<small class="text-muted">{{ member.email }}</small>
</div>
</div>
<div class="d-flex align-items-center gap-2">
{% if member.id != room.created_by %}
<form action="{{ url_for('rooms.update_member_permissions', room_id=room.id, user_id=member.id) }}" method="POST" class="d-flex align-items-center gap-2 auto-save-perms-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_view" id="can_view_{{ member.id }}" checked disabled>
<input type="hidden" name="can_view" value="1">
<label class="form-check-label" for="can_view_{{ member.id }}" title="View permission is always required"><i class="fas fa-eye"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_download" id="can_download_{{ member.id }}" {% if perm.can_download %}checked{% endif %}>
<label class="form-check-label" for="can_download_{{ member.id }}" title="Can Download Files"><i class="fas fa-download"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_upload" id="can_upload_{{ member.id }}" {% if perm.can_upload %}checked{% endif %}>
<label class="form-check-label" for="can_upload_{{ member.id }}" title="Can Upload Files"><i class="fas fa-upload"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_delete" id="can_delete_{{ member.id }}" {% if perm.can_delete %}checked{% endif %}>
<label class="form-check-label" for="can_delete_{{ member.id }}" title="Can Delete Files"><i class="fas fa-trash"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_rename" id="can_rename_{{ member.id }}" {% if perm.can_rename %}checked{% endif %}>
<label class="form-check-label" for="can_rename_{{ member.id }}" title="Can Rename Files"><i class="fas fa-i-cursor"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_move" id="can_move_{{ member.id }}" {% if perm.can_move %}checked{% endif %}>
<label class="form-check-label" for="can_move_{{ member.id }}" title="Can Move Files"><i class="fas fa-arrows-alt"></i></label>
</div>
<div class="form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" name="can_share" id="can_share_{{ member.id }}" {% if perm.can_share %}checked{% endif %}>
<label class="form-check-label" for="can_share_{{ member.id }}" title="Can Share Files"><i class="fas fa-share-alt"></i></label>
</div>
</form>
<form action="{{ url_for('rooms.remove_member', room_id=room.id, user_id=member.id) }}" method="POST" class="d-inline ms-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-remove-member px-3 d-flex align-items-center gap-1">
<i class="fas fa-user-minus"></i> Remove
</button>
</form>
{% else %}
<span class="badge creator-badge px-3 py-2 d-flex align-items-center gap-1" style="height: 32px;">
<i class="fas fa-user"></i> Creator
</span>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% else %}
<p class="text-muted mb-0">No members in this room yet.</p>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="card-title mb-0">Add New Member</h5>
</div>
<div class="card-body">
<form action="{{ url_for('rooms.add_member', room_id=room.id) }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="mb-3">
<label for="user_id" class="form-label">Select User</label>
<select class="form-select select2" id="user_id" name="user_id" required>
<option value="">Search for a user...</option>
{% for user in available_users %}
<option value="{{ user.id }}">{{ user.username }} {{ user.last_name }} ({{ user.email }})</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-user-plus me-2"></i>Add Member
</button>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
$(document).ready(function() {
$('.select2').select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Search for a user...',
allowClear: true
});
// Auto-submit permission form on checkbox change
document.querySelectorAll('.auto-save-perms-form input[type="checkbox"]').forEach(function(checkbox) {
checkbox.addEventListener('change', function() {
this.closest('form').submit();
});
});
});
</script>
{% endblock %}

196
templates/rooms.html Normal file
View File

@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% block title %}Rooms - DocuPulse{% endblock %}
{% block content %}
<div class="container mt-4">
<div class="row mb-4">
<div class="col">
<h2>Rooms</h2>
</div>
<div class="col text-end">
<a href="{{ url_for('rooms.create_room') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> New Room
</a>
</div>
</div>
<!-- Search and Filter Section -->
<div class="bg-white rounded-lg shadow-sm p-4 mb-4">
<form method="GET" class="d-flex align-items-center w-100 justify-content-between" id="roomFilterForm" style="gap: 1rem;">
<input type="text" name="search" placeholder="Search rooms..." value="{{ search }}" class="form-control flex-grow-1" id="roomSearchInput" autocomplete="off" style="min-width: 0;" />
<button type="button" id="clearRoomsFilter" class="px-4 py-2 rounded-lg text-white font-medium transition-colors duration-200 ms-2 flex-shrink-0"
style="background-color: #16767b; border: 1px solid #16767b;"
onmouseover="this.style.backgroundColor='#1a8a90'"
onmouseout="this.style.backgroundColor='#16767b'">
Clear
</button>
</form>
</div>
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-4">
{% for room in rooms %}
<div class="col">
<div class="card h-100 shadow-sm hover-shadow">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h5 class="card-title mb-0">{{ room.name }}</h5>
<div class="text-muted small mt-1">Created on {{ room.created_at.strftime('%b %d, %Y') }}</div>
</div>
<span class="badge bg-light text-dark">
<i class="fas fa-users"></i> {{ room.member_permissions|length }}
</span>
</div>
<p class="card-text text-muted">{{ room.description or 'No description' }}</p>
<div class="d-flex align-items-center mt-3">
<img src="{{ url_for('profile_pic', filename=room.creator.profile_picture) if room.creator.profile_picture else url_for('static', filename='default-avatar.png') }}"
alt="Creator"
class="rounded-circle me-2"
style="width: 32px; height: 32px; object-fit: cover;">
<div>
<small class="text-muted">Created by</small>
<div class="fw-medium">{{ room.creator.username }} {{ room.creator.last_name }}</div>
</div>
</div>
</div>
<div class="card-footer bg-transparent border-top-0">
<div class="d-flex gap-2">
<a href="{{ url_for('rooms.room', room_id=room.id) }}" class="btn btn-primary flex-grow-1">
<i class="fas fa-door-open me-2"></i>Open Room
</a>
{% if current_user.is_admin %}
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="roomActions{{ room.id }}" data-bs-toggle="dropdown" aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="roomActions{{ room.id }}">
<li>
<a class="dropdown-item" href="{{ url_for('rooms.edit_room', room_id=room.id) }}">
<i class="fas fa-edit me-2"></i>Edit Room
</a>
</li>
<li>
<a class="dropdown-item" href="{{ url_for('rooms.room_members', room_id=room.id) }}">
<i class="fas fa-users me-2"></i>Manage Members
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<button type="button" class="dropdown-item text-danger" data-bs-toggle="modal" data-bs-target="#deleteRoomModal{{ room.id }}">
<i class="fas fa-trash me-2"></i>Delete Room
</button>
</li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
</div>
{% if current_user.is_admin %}
<!-- Delete Room Modal -->
<div class="modal fade" id="deleteRoomModal{{ room.id }}" tabindex="-1" aria-labelledby="deleteRoomModalLabel{{ room.id }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteRoomModalLabel{{ room.id }}">Move to Trash</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-3">
<i class="fas fa-trash text-danger" style="font-size: 2rem;"></i>
<div>
<h6 class="mb-1">Move to Trash</h6>
<p class="text-muted mb-0" id="deleteFileName{{ room.id }}">{{ room.name }}</p>
</div>
</div>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
This item will be moved to trash. You can restore it from the trash page within 30 days.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<form action="{{ url_for('rooms.delete_room', room_id=room.id) }}" method="POST" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Move to Trash
</button>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
</div>
<style>
.hover-shadow {
transition: all 0.3s ease;
}
.hover-shadow:hover {
transform: translateY(-5px);
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important;
}
.card {
border: none;
border-radius: 10px;
}
.card-footer {
padding: 1rem;
}
.btn {
border-radius: 5px;
font-weight: 500;
padding: 0.5rem 1rem;
transition: all 0.2s ease;
}
.btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn i {
font-size: 0.9em;
}
</style>
{% block extra_js %}
<script>
// Debounce function
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('roomSearchInput');
const form = document.getElementById('roomFilterForm');
if (searchInput && form) {
searchInput.addEventListener('input', debounce(function() {
form.submit();
}, 300));
}
// Clear button logic
const clearBtn = document.getElementById('clearRoomsFilter');
if (clearBtn && searchInput) {
clearBtn.addEventListener('click', function() {
searchInput.value = '';
form.submit();
});
}
});
</script>
{% endblock %}
{% endblock %}

468
templates/starred.html Normal file
View File

@@ -0,0 +1,468 @@
{% extends "base.html" %}
{% block title %}Starred - DocuPulse{% endblock %}
{% block content %}
<meta name="csrf-token" content="{{ csrf_token }}">
<div class="container mt-4">
<div class="row mb-4">
<div class="col">
<h2>Starred Items</h2>
<div class="text-muted">Your starred files and folders from all rooms</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<div class="d-flex align-items-center gap-3">
<div class="btn-group btn-group-sm" role="group" style="margin-right: 0.5rem;">
<button type="button" id="gridViewBtn" class="btn btn-outline-secondary active" onclick="toggleView('grid')">
<i class="fas fa-th-large"></i>
</button>
<button type="button" id="listViewBtn" class="btn btn-outline-secondary" onclick="toggleView('list')">
<i class="fas fa-list"></i>
</button>
</div>
<h5 class="mb-0">Files</h5>
</div>
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<div class="ms-auto" style="max-width: 300px; position: relative;">
<input type="text" id="quickSearchInput" class="form-control form-control-sm" placeholder="Quick search files..." autocomplete="off" style="padding-right: 2rem;" />
<button id="clearSearchBtn" type="button" style="position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%); display: none; border: none; background: transparent; font-size: 1.2rem; color: #888; cursor: pointer; z-index: 2;">&times;</button>
</div>
</div>
<div id="fileGrid" class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-4"></div>
<div id="fileError" class="text-danger mt-2"></div>
</div>
</div>
</div>
<!-- Details Modal -->
<div id="detailsModal" class="modal fade" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detailsModalLabel">Item Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="detailsModalBody">
<!-- Populated by JS -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script>
let currentView = 'grid';
let lastSelectedIndex = -1;
let sortColumn = 'name'; // Set default sort column to name
let sortDirection = 1; // 1 for ascending, -1 for descending
let batchDeleteItems = null;
let currentFiles = [];
// Initialize the view and fetch files
async function initializeView() {
try {
const response = await fetch('/api/user/preferred_view');
const data = await response.json();
currentView = data.preferred_view || 'grid';
} catch (error) {
console.error('Error fetching preferred view:', error);
currentView = 'grid';
}
// First fetch files
await fetchFiles();
// Then toggle view after files are loaded
toggleView(currentView);
// Sort files by name by default
sortFiles('name');
}
function formatDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function toggleView(view) {
currentView = view;
const grid = document.getElementById('fileGrid');
const gridBtn = document.getElementById('gridViewBtn');
const listBtn = document.getElementById('listViewBtn');
if (view === 'grid') {
grid.classList.remove('table-mode');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
} else {
grid.classList.add('table-mode');
gridBtn.classList.remove('active');
listBtn.classList.add('active');
}
renderFiles(currentFiles);
// Save the new preference
fetch('/api/user/preferred_view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({ preferred_view: view })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Preferred view saved:', data);
})
.catch(error => console.error('Error saving preferred view:', error));
}
function sortFiles(column) {
if (sortColumn === column) {
sortDirection *= -1; // Toggle direction
} else {
sortColumn = column;
sortDirection = 1;
}
currentFiles.sort((a, b) => {
let valA = a[column];
let valB = b[column];
// For size, convert to number
if (column === 'size') {
valA = typeof valA === 'number' ? valA : 0;
valB = typeof valB === 'number' ? valB : 0;
}
// For name/type, compare as strings
if (typeof valA === 'string' && typeof valB === 'string') {
return valA.localeCompare(valB) * sortDirection;
}
// For date (modified), compare as numbers
return (valA - valB) * sortDirection;
});
renderFiles(currentFiles);
}
function renderFiles(files) {
if (!files) return;
currentFiles = files;
const grid = document.getElementById('fileGrid');
grid.innerHTML = '';
if (!files.length) {
grid.innerHTML = '<div class="col"><div class="text-muted">No starred items yet.</div></div>';
return;
}
if (currentView === 'list') {
let table = `<table><thead><tr>
<th></th>
<th>Room</th>
<th onclick="sortFiles('name')" style="cursor:pointer;">Name ${(sortColumn==='name') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('modified')" style="cursor:pointer;">Date Modified ${(sortColumn==='modified') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('type')" style="cursor:pointer;">Type ${(sortColumn==='type') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('size')" style="cursor:pointer;">Size ${(sortColumn==='size') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th class='file-actions'></th>
</tr></thead><tbody>`;
files.forEach((file, idx) => {
let icon = file.type === 'folder'
? `<i class='fas fa-folder' style='font-size:1.5rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:1.5rem;color:#741b5f;'></i>`;
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
let actionsArr = [];
let dblClickAction = '';
if (file.type === 'folder') {
dblClickAction = `ondblclick=\"window.location.href='/room/${file.room_id}?path=${encodeURIComponent(file.path ? file.path + '/' + file.name : file.name)}'\"`;
} else {
dblClickAction = `ondblclick=\"window.location.href='/api/rooms/${file.room_id}/files/${encodeURIComponent(file.name)}?path=${encodeURIComponent(file.path)}'\"`;
}
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='${file.starred ? 'Unstar' : 'Star'}' style='background-color:${file.starred ? 'rgba(255,215,0,0.15)' : 'rgba(22,118,123,0.08)'};color:${file.starred ? '#ffd700' : '#16767b'};' onclick='event.stopPropagation();toggleStar("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-star'></i></button>`);
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Details' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();showDetailsModal(${idx})'><i class='fas fa-info-circle'></i></button>`);
const actions = actionsArr.join('');
table += `<tr ${dblClickAction} onclick='navigateToFile(${file.room_id}, "${file.name}", "${file.path}", "${file.type}")'>
<td class='file-icon'><span style="display:inline-flex;align-items:center;gap:1.2rem;">${icon}</span></td>
<td class='room-name'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></td>
<td class='file-name' title='${file.name}'>${file.name}</td>
<td class='file-date'>${formatDate(file.modified)}</td>
<td class='file-type'>${file.type}</td>
<td class='file-size'>${size}</td>
<td class='file-actions'>${actions}</td>
</tr>`;
});
table += '</tbody></table>';
grid.innerHTML = table;
} else {
files.forEach((file, idx) => {
let icon = file.type === 'folder'
? `<i class='fas fa-folder' style='font-size:2.5rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:2.5rem;color:#741b5f;'></i>`;
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
let actionsArr = [];
let dblClickAction = '';
if (file.type === 'folder') {
dblClickAction = `ondblclick=\"window.location.href='/room/${file.room_id}?path=${encodeURIComponent(file.path ? file.path + '/' + file.name : file.name)}'\"`;
} else {
dblClickAction = `ondblclick=\"window.location.href='/api/rooms/${file.room_id}/files/${encodeURIComponent(file.name)}?path=${encodeURIComponent(file.path)}'\"`;
}
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='${file.starred ? 'Unstar' : 'Star'}' style='background-color:${file.starred ? 'rgba(255,215,0,0.15)' : 'rgba(22,118,123,0.08)'};color:${file.starred ? '#ffd700' : '#16767b'};' onclick='event.stopPropagation();toggleStar("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-star'></i></button>`);
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Details' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();showDetailsModal(${idx})'><i class='fas fa-info-circle'></i></button>`);
const actions = actionsArr.join('');
grid.innerHTML += `
<div class='col'>
<div class='card file-card h-100 border-0 shadow-sm position-relative' ${dblClickAction} onclick='navigateToFile(${file.room_id}, "${file.name}", "${file.path}", "${file.type}")'>
<div class='card-body d-flex flex-column align-items-center justify-content-center text-center'>
<div class='mb-2'>${icon}</div>
<div class='fw-semibold file-name-ellipsis' title='${file.name}'>${file.name}</div>
<div class='text-muted small'>${formatDate(file.modified)}</div>
<div class='text-muted small'>${size}</div>
<div class='mt-2'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='event.stopPropagation();window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></div>
</div>
<div class='card-footer bg-white border-0 d-flex justify-content-center gap-2'>${actions}</div>
</div>
</div>`;
});
}
}
function fetchFiles() {
fetch('/api/rooms/starred')
.then(r => r.json())
.then(files => {
if (files) {
window.currentFiles = files;
// Sort files by name by default
window.currentFiles.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
renderFiles(files);
}
})
.catch(error => {
console.error('Error loading files:', error);
document.getElementById('fileGrid').innerHTML = '<div class="col"><div class="text-danger">Failed to load files. Please try refreshing the page.</div></div>';
});
}
function toggleStar(filename, path = '', roomId) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch(`/api/rooms/${roomId}/star`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
filename: filename,
path: path
})
})
.then(r => r.json())
.then(res => {
if (res.success) {
// Remove the file from the current view since it's no longer starred
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
renderFiles(currentFiles);
} else {
console.error('Failed to toggle star:', res.error);
}
})
.catch(error => {
console.error('Error toggling star:', error);
});
}
function navigateToFile(roomId, filename, path, type) {
if (file.type === 'folder') {
window.location.href = `/room/${roomId}?path=${encodeURIComponent(path ? path + '/' + filename : filename)}`;
} else {
window.location.href = `/api/rooms/${roomId}/files/${encodeURIComponent(filename)}?path=${encodeURIComponent(path)}`;
}
}
function showDetailsModal(idx) {
const item = currentFiles[idx];
const icon = item.type === 'folder'
? `<i class='fas fa-folder' style='font-size:2.2rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:2.2rem;color:#741b5f;'></i>`;
const uploaderPic = item.uploader_profile_pic
? `/uploads/profile_pics/${item.uploader_profile_pic}`
: '/static/default-avatar.png';
const detailsHtml = `
<div class='d-flex align-items-center gap-3 mb-3'>
<div>${icon}</div>
<div style='min-width:0;'>
<div class='fw-bold' style='font-size:1.1rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' title='${item.name}'>${item.name}</div>
<div class='text-muted small'>${item.type === 'folder' ? 'Folder' : 'File'}</div>
</div>
</div>
<div class='mb-2 d-flex align-items-center gap-2'>
<img src='${uploaderPic}' alt='Profile Picture' class='rounded-circle border' style='width:28px;height:28px;object-fit:cover;'>
<span class='fw-semibold' style='font-size:0.98rem;'>${item.uploaded_by || '-'}</span>
</div>
<div class='mb-2 text-muted' style='font-size:0.92rem;'><i class='far fa-clock me-1'></i>${formatDate(item.modified)}</div>
<hr style='margin:0.7rem 0 0.5rem 0; border-color:#e6f3f4;'>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Room:</strong> <button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href=\"/room/${item.room_id}\"'><i class='fas fa-door-open me-1'></i>${item.room_name || 'Room ' + item.room_id}</button></div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Path:</strong> <span style='word-break:break-all;'>${(item.path ? item.path + '/' : '') + item.name}</span></div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Size:</strong> ${item.size === '-' ? '-' : (item.size > 0 ? (item.size < 1024*1024 ? (item.size/1024).toFixed(1)+' KB' : (item.size/1024/1024).toFixed(2)+' MB') : '0 KB')}</div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Uploaded at:</strong> ${item.uploaded_at ? new Date(item.uploaded_at).toLocaleString() : '-'}</div>
`;
document.getElementById('detailsModalBody').innerHTML = detailsHtml;
var modal = new bootstrap.Modal(document.getElementById('detailsModal'));
modal.show();
}
// Live search
document.addEventListener('DOMContentLoaded', function() {
initializeView();
const quickSearchInput = document.getElementById('quickSearchInput');
const clearSearchBtn = document.getElementById('clearSearchBtn');
let searchTimeout = null;
quickSearchInput.addEventListener('input', function() {
const query = quickSearchInput.value.trim().toLowerCase();
clearSearchBtn.style.display = query.length > 0 ? 'block' : 'none';
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (query.length === 0) {
fetchFiles();
return;
}
const filteredFiles = currentFiles.filter(file =>
file.name.toLowerCase().includes(query)
);
renderFiles(filteredFiles);
}, 200);
});
clearSearchBtn.addEventListener('click', function() {
quickSearchInput.value = '';
clearSearchBtn.style.display = 'none';
fetchFiles();
});
});
</script>
<style>
.file-name-ellipsis {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: bottom;
}
@media (min-width: 992px) {
.file-name-ellipsis { max-width: 180px; }
}
.file-action-btn {
min-width: 32px;
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 5px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.card:hover > .card-footer .file-action-btn {
opacity: 1;
pointer-events: auto;
}
.card-footer.bg-white.border-0.d-flex.justify-content-center.gap-2 {
min-height: 40px;
}
.card.file-card {
cursor: pointer;
}
.card.file-card .file-action-btn {
cursor: pointer;
}
#fileGrid.table-mode {
padding: 0;
}
#fileGrid.table-mode table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
#fileGrid.table-mode th, #fileGrid.table-mode td {
padding: 0.5rem 1rem;
border-bottom: 1px solid #e9ecef;
text-align: left;
font-size: 0.95rem;
vertical-align: middle;
}
#fileGrid.table-mode th {
background: #f8f9fa;
color: #6c757d;
font-weight: 500;
}
#fileGrid.table-mode tr:hover td {
background-color: rgba(22, 118, 123, 0.08);
transition: background 0.15s;
}
#fileGrid.table-mode .file-icon {
width: 40px;
text-align: center;
}
#fileGrid.table-mode .file-actions {
min-width: 90px;
text-align: right;
}
#fileGrid.table-mode .file-action-btn {
opacity: 1;
pointer-events: auto;
min-width: 28px;
min-height: 28px;
font-size: 0.875rem;
margin-left: 0.25rem;
}
/* Disable text selection for file grid and table rows/cards */
#fileGrid, #fileGrid * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#fileGrid .card.file-card {
cursor: pointer;
}
#fileGrid.table-mode tr {
cursor: pointer;
}
.btn-group.btn-group-sm .btn {
background-color: #fff;
border-color: #e9ecef;
color: #6c757d;
transition: background-color 0.15s, color 0.15s;
}
.btn-group.btn-group-sm .btn.active, .btn-group.btn-group-sm .btn:active {
background-color: #e6f3f4 !important;
color: #16767b !important;
border-color: #16767b !important;
box-shadow: none;
}
.btn-group.btn-group-sm .btn:focus {
box-shadow: 0 0 0 0.1rem #16767b33;
}
.btn-group.btn-group-sm .btn:hover:not(.active) {
background-color: #f8f9fa;
color: #16767b;
}
</style>
{% endblock %}

643
templates/trash.html Normal file
View File

@@ -0,0 +1,643 @@
{% extends "base.html" %}
{% block title %}Trash - DocuPulse{% endblock %}
{% block content %}
<meta name="csrf-token" content="{{ csrf_token }}">
<div class="container mt-4">
<div class="row mb-4">
<div class="col">
<h2>Trash</h2>
<div class="text-muted">Your deleted files and folders from all rooms</div>
</div>
</div>
<div class="card shadow-sm mb-4">
<div class="card-header d-flex justify-content-between align-items-center bg-white">
<div class="d-flex align-items-center gap-3">
<div class="btn-group btn-group-sm" role="group" style="margin-right: 0.5rem;">
<button type="button" id="gridViewBtn" class="btn btn-outline-secondary active" onclick="toggleView('grid')">
<i class="fas fa-th-large"></i>
</button>
<button type="button" id="listViewBtn" class="btn btn-outline-secondary" onclick="toggleView('list')">
<i class="fas fa-list"></i>
</button>
</div>
<h5 class="mb-0">Files</h5>
</div>
{% if current_user.is_admin %}
<button type="button" id="emptyTrashBtn" class="btn btn-danger btn-sm" onclick="showEmptyTrashModal()">
<i class="fas fa-trash me-1"></i>Empty Trash
</button>
{% endif %}
</div>
<div class="card-body">
<div class="d-flex align-items-center gap-2 mb-3">
<div class="ms-auto" style="max-width: 300px; position: relative;">
<input type="text" id="quickSearchInput" class="form-control form-control-sm" placeholder="Quick search files..." autocomplete="off" style="padding-right: 2rem;" />
<button id="clearSearchBtn" type="button" style="position: absolute; right: 0.25rem; top: 50%; transform: translateY(-50%); display: none; border: none; background: transparent; font-size: 1.2rem; color: #888; cursor: pointer; z-index: 2;">&times;</button>
</div>
</div>
<div id="fileGrid" class="row row-cols-1 row-cols-md-2 row-cols-lg-4 g-4"></div>
<div id="fileError" class="text-danger mt-2"></div>
</div>
</div>
</div>
<!-- Details Modal -->
<div id="detailsModal" class="modal fade" tabindex="-1" aria-labelledby="detailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="detailsModalLabel">Item Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" id="detailsModalBody">
<!-- Populated by JS -->
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Permanent Delete Confirmation Modal -->
<div id="permanentDeleteModal" class="modal fade" tabindex="-1" aria-labelledby="permanentDeleteModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="permanentDeleteModalLabel">Permanently Delete Item</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-3">
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 2rem;"></i>
<div>
<h6 class="mb-1">Are you sure you want to permanently delete this item?</h6>
<p class="text-muted mb-0" id="permanentDeleteItemName"></p>
</div>
</div>
<div class="alert alert-danger">
<i class="fas fa-info-circle me-2"></i>
This action cannot be undone. The item will be permanently removed from the system.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmPermanentDelete">
<i class="fas fa-trash me-1"></i>Delete Permanently
</button>
</div>
</div>
</div>
</div>
<!-- Empty Trash Confirmation Modal -->
<div id="emptyTrashModal" class="modal fade" tabindex="-1" aria-labelledby="emptyTrashModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="emptyTrashModalLabel">Empty Trash</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="d-flex align-items-center gap-3 mb-3">
<i class="fas fa-exclamation-triangle text-danger" style="font-size: 2rem;"></i>
<div>
<h6 class="mb-1">Are you sure you want to empty the trash?</h6>
<p class="text-muted mb-0">This will permanently delete all items in the trash.</p>
</div>
</div>
<div class="alert alert-danger">
<i class="fas fa-info-circle me-2"></i>
This action cannot be undone. All items will be permanently removed from the system.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmEmptyTrash">
<i class="fas fa-trash me-1"></i>Empty Trash
</button>
</div>
</div>
</div>
</div>
<script>
let currentView = 'grid';
let lastSelectedIndex = -1;
let sortColumn = 'auto_delete'; // Set default sort column to auto_delete
let sortDirection = 1; // 1 for ascending, -1 for descending
let batchDeleteItems = null;
let currentFiles = [];
let fileToDelete = null;
window.isAdmin = {{ 'true' if current_user.is_admin else 'false' }};
// Initialize the view and fetch files
async function initializeView() {
try {
const response = await fetch('/api/user/preferred_view');
const data = await response.json();
currentView = data.preferred_view || 'grid';
} catch (error) {
console.error('Error fetching preferred view:', error);
currentView = 'grid';
}
// First fetch files
await fetchFiles();
// Then toggle view after files are loaded
toggleView(currentView);
// Sort files by auto_delete by default
sortFiles('auto_delete');
}
function formatDate(ts) {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
}
function toggleView(view) {
currentView = view;
const grid = document.getElementById('fileGrid');
const gridBtn = document.getElementById('gridViewBtn');
const listBtn = document.getElementById('listViewBtn');
if (view === 'grid') {
grid.classList.remove('table-mode');
gridBtn.classList.add('active');
listBtn.classList.remove('active');
} else {
grid.classList.add('table-mode');
gridBtn.classList.remove('active');
listBtn.classList.add('active');
}
renderFiles(currentFiles);
// Save the new preference
fetch('/api/user/preferred_view', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({ preferred_view: view })
})
.then(response => {
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
})
.then(data => {
console.log('Preferred view saved:', data);
})
.catch(error => console.error('Error saving preferred view:', error));
}
function sortFiles(column) {
if (sortColumn === column) {
sortDirection *= -1; // Toggle direction
} else {
sortColumn = column;
sortDirection = 1;
}
currentFiles.sort((a, b) => {
let valA = a[column];
let valB = b[column];
// Special handling for auto_delete column
if (column === 'auto_delete') {
valA = valA ? new Date(valA).getTime() : Infinity;
valB = valB ? new Date(valB).getTime() : Infinity;
return (valA - valB) * sortDirection;
}
// For size, convert to number
if (column === 'size') {
valA = typeof valA === 'number' ? valA : 0;
valB = typeof valB === 'number' ? valB : 0;
}
// For name/type, compare as strings
if (typeof valA === 'string' && typeof valB === 'string') {
return valA.localeCompare(valB) * sortDirection;
}
// For date (modified), compare as numbers
return (valA - valB) * sortDirection;
});
renderFiles(currentFiles);
}
function renderFiles(files) {
if (!files) return;
currentFiles = files;
const grid = document.getElementById('fileGrid');
grid.innerHTML = '';
if (!files.length) {
grid.innerHTML = '<div class="col"><div class="text-muted">No deleted items yet.</div></div>';
return;
}
if (currentView === 'list') {
let table = `<table><thead><tr>
<th></th>
<th>Room</th>
<th onclick="sortFiles('name')" style="cursor:pointer;">Name ${(sortColumn==='name') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('modified')" style="cursor:pointer;">Date Modified ${(sortColumn==='modified') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('type')" style="cursor:pointer;">Type ${(sortColumn==='type') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th onclick="sortFiles('size')" style="cursor:pointer;">Size ${(sortColumn==='size') ? (sortDirection===1?'▲':'▼') : ''}</th>
<th>Deleted By</th>
<th>Deleted At</th>
<th>Auto-Delete</th>
<th class='file-actions'></th>
</tr></thead><tbody>`;
files.forEach((file, idx) => {
let icon = file.type === 'folder'
? `<i class='fas fa-folder' style='font-size:1.5rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:1.5rem;color:#741b5f;'></i>`;
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
let actionsArr = [];
if (file.can_restore) {
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Restore' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='restoreFile("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-undo'></i></button>`);
}
if (window.isAdmin) {
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Delete Permanently' style='background-color:rgba(239,68,68,0.1);color:#b91c1c;' onclick='deletePermanently("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-trash'></i></button>`);
}
const actions = actionsArr.join('');
// Calculate days until auto-deletion
const deletedAt = new Date(file.deleted_at);
const now = new Date();
const daysUntilDeletion = 30 - Math.floor((now - deletedAt) / (1000 * 60 * 60 * 24));
let autoDeleteStatus = '';
if (daysUntilDeletion <= 0) {
autoDeleteStatus = '<span class="badge bg-danger">Deleting soon</span>';
} else if (daysUntilDeletion <= 7) {
autoDeleteStatus = `<span class="badge bg-warning text-dark">${daysUntilDeletion} days left</span>`;
} else {
autoDeleteStatus = `<span class="badge bg-info text-dark">${daysUntilDeletion} days left</span>`;
}
table += `<tr>
<td class='file-icon'><span style="display:inline-flex;align-items:center;gap:1.2rem;">${icon}</span></td>
<td class='room-name'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></td>
<td class='file-name' title='${file.name}'>${file.name}</td>
<td class='file-date'>${formatDate(file.modified)}</td>
<td class='file-type'>${file.type}</td>
<td class='file-size'>${size}</td>
<td class='deleted-by'>${file.deleted_by || '-'}</td>
<td class='deleted-at'>${file.deleted_at ? new Date(file.deleted_at).toLocaleString() : '-'}</td>
<td class='auto-delete'>${autoDeleteStatus}</td>
<td class='file-actions'>${actions}</td>
</tr>`;
});
table += '</tbody></table>';
grid.innerHTML = table;
} else {
files.forEach((file, idx) => {
let icon = file.type === 'folder'
? `<i class='fas fa-folder' style='font-size:2.5rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:2.5rem;color:#741b5f;'></i>`;
let size = file.size !== '-' ? (file.size > 0 ? (file.size < 1024*1024 ? (file.size/1024).toFixed(1)+' KB' : (file.size/1024/1024).toFixed(2)+' MB') : '0 KB') : '-';
let actionsArr = [];
if (file.can_restore) {
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Restore' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='restoreFile("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-undo'></i></button>`);
}
if (window.isAdmin) {
actionsArr.push(`<button class='btn btn-sm file-action-btn' title='Delete Permanently' style='background-color:rgba(239,68,68,0.1);color:#b91c1c;' onclick='deletePermanently("${file.name}", "${file.path}", ${file.room_id})'><i class='fas fa-trash'></i></button>`);
}
const actions = actionsArr.join('');
// Calculate days until auto-deletion
const deletedAt = new Date(file.deleted_at);
const now = new Date();
const daysUntilDeletion = 30 - Math.floor((now - deletedAt) / (1000 * 60 * 60 * 24));
let autoDeleteStatus = '';
if (daysUntilDeletion <= 0) {
autoDeleteStatus = '<span class="badge bg-danger">Deleting soon</span>';
} else if (daysUntilDeletion <= 7) {
autoDeleteStatus = `<span class="badge bg-warning text-dark">${daysUntilDeletion} days left</span>`;
} else {
autoDeleteStatus = `<span class="badge bg-info text-dark">${daysUntilDeletion} days left</span>`;
}
grid.innerHTML += `
<div class='col'>
<div class='card file-card h-100 border-0 shadow-sm position-relative'>
<div class='card-body d-flex flex-column align-items-center justify-content-center text-center'>
<div class='mb-2'>${icon}</div>
<div class='fw-semibold file-name-ellipsis' title='${file.name}'>${file.name}</div>
<div class='text-muted small'>${formatDate(file.modified)}</div>
<div class='text-muted small'>${size}</div>
<div class='mt-2'><button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href="/rooms/${file.room_id}"'><i class='fas fa-door-open me-1'></i>${file.room_name || 'Room ' + file.room_id}</button></div>
<div class='text-muted small mt-1'>Deleted by: ${file.deleted_by || '-'}</div>
<div class='text-muted small'>${file.deleted_at ? new Date(file.deleted_at).toLocaleString() : '-'}</div>
<div class='mt-2'>${autoDeleteStatus}</div>
</div>
<div class='card-footer bg-white border-0 d-flex justify-content-center gap-2'>${actions}</div>
</div>
</div>`;
});
}
}
function fetchFiles() {
fetch('/api/rooms/trash')
.then(r => r.json())
.then(files => {
if (files) {
window.currentFiles = files;
// Sort files by auto_delete by default
window.currentFiles.sort((a, b) => {
const timeA = a.auto_delete ? new Date(a.auto_delete).getTime() : Infinity;
const timeB = b.auto_delete ? new Date(b.auto_delete).getTime() : Infinity;
return timeA - timeB;
});
renderFiles(files);
}
})
.catch(error => {
console.error('Error loading files:', error);
document.getElementById('fileGrid').innerHTML = '<div class="col"><div class="text-danger">Failed to load files. Please try refreshing the page.</div></div>';
});
}
function restoreFile(filename, path, roomId) {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch(`/api/rooms/${roomId}/restore`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
filename: filename,
path: path
})
})
.then(r => r.json())
.then(res => {
if (res.success) {
// Remove the file from the current view since it's been restored
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
renderFiles(currentFiles);
} else {
console.error('Failed to restore file:', res.error);
}
})
.catch(error => {
console.error('Error restoring file:', error);
});
}
function deletePermanently(filename, path, roomId) {
fileToDelete = { filename, path, roomId };
const modal = new bootstrap.Modal(document.getElementById('permanentDeleteModal'));
document.getElementById('permanentDeleteItemName').textContent = filename;
modal.show();
}
document.getElementById('confirmPermanentDelete').addEventListener('click', function() {
if (!fileToDelete) return;
const { filename, path, roomId } = fileToDelete;
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
fetch(`/api/rooms/${roomId}/delete-permanent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
filename: filename,
path: path
})
})
.then(r => r.json())
.then(res => {
if (res.success) {
// Remove the file from the current view since it's been permanently deleted
currentFiles = currentFiles.filter(f => !(f.name === filename && f.path === path && f.room_id === roomId));
renderFiles(currentFiles);
// Close the modal
bootstrap.Modal.getInstance(document.getElementById('permanentDeleteModal')).hide();
} else {
console.error('Failed to delete file permanently:', res.error);
}
})
.catch(error => {
console.error('Error deleting file permanently:', error);
})
.finally(() => {
fileToDelete = null;
});
});
function showDetailsModal(idx) {
const item = currentFiles[idx];
const icon = item.type === 'folder'
? `<i class='fas fa-folder' style='font-size:2.2rem;color:#16767b;'></i>`
: `<i class='fas fa-file-alt' style='font-size:2.2rem;color:#741b5f;'></i>`;
const uploaderPic = item.uploader_profile_pic
? `/uploads/profile_pics/${item.uploader_profile_pic}`
: '/static/default-avatar.png';
const detailsHtml = `
<div class='d-flex align-items-center gap-3 mb-3'>
<div>${icon}</div>
<div style='min-width:0;'>
<div class='fw-bold' style='font-size:1.1rem;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;' title='${item.name}'>${item.name}</div>
<div class='text-muted small'>${item.type === 'folder' ? 'Folder' : 'File'}</div>
</div>
</div>
<div class='mb-2 d-flex align-items-center gap-2'>
<img src='${uploaderPic}' alt='Profile Picture' class='rounded-circle border' style='width:28px;height:28px;object-fit:cover;'>
<span class='fw-semibold' style='font-size:0.98rem;'>${item.uploaded_by || '-'}</span>
</div>
<div class='mb-2 text-muted' style='font-size:0.92rem;'><i class='far fa-clock me-1'></i>${formatDate(item.modified)}</div>
<hr style='margin:0.7rem 0 0.5rem 0; border-color:#e6f3f4;'>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Room:</strong> <button class='btn btn-sm' style='background-color:rgba(22,118,123,0.08);color:#16767b;' onclick='window.location.href=\"/room/${item.room_id}\"'><i class='fas fa-door-open me-1'></i>${item.room_name || 'Room ' + item.room_id}</button></div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Path:</strong> <span style='word-break:break-all;'>${(item.path ? item.path + '/' : '') + item.name}</span></div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Size:</strong> ${item.size === '-' ? '-' : (item.size > 0 ? (item.size < 1024*1024 ? (item.size/1024).toFixed(1)+' KB' : (item.size/1024/1024).toFixed(2)+' MB') : '0 KB')}</div>
<div style='font-size:0.91rem;color:#555;'><strong style='color:#16767b;'>Uploaded at:</strong> ${item.uploaded_at ? new Date(item.uploaded_at).toLocaleString() : '-'}</div>
`;
document.getElementById('detailsModalBody').innerHTML = detailsHtml;
var modal = new bootstrap.Modal(document.getElementById('detailsModal'));
modal.show();
}
// Live search
document.addEventListener('DOMContentLoaded', function() {
initializeView();
const quickSearchInput = document.getElementById('quickSearchInput');
const clearSearchBtn = document.getElementById('clearSearchBtn');
let searchTimeout = null;
quickSearchInput.addEventListener('input', function() {
const query = quickSearchInput.value.trim().toLowerCase();
clearSearchBtn.style.display = query.length > 0 ? 'block' : 'none';
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
if (query.length === 0) {
fetchFiles();
return;
}
const filteredFiles = currentFiles.filter(file =>
file.name.toLowerCase().includes(query)
);
renderFiles(filteredFiles);
}, 200);
});
clearSearchBtn.addEventListener('click', function() {
quickSearchInput.value = '';
clearSearchBtn.style.display = 'none';
fetchFiles();
});
});
function showEmptyTrashModal() {
const modal = new bootstrap.Modal(document.getElementById('emptyTrashModal'));
modal.show();
}
document.getElementById('confirmEmptyTrash').addEventListener('click', function() {
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
// Get all unique room IDs from current files
const roomIds = [...new Set(currentFiles.map(file => file.room_id))];
// Create an array of promises for emptying trash in each room
const emptyPromises = roomIds.map(roomId =>
fetch(`/api/rooms/${roomId}/delete-permanent`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({
filename: '*', // Special value to indicate all files
path: '' // Empty path to indicate root
})
})
);
// Execute all promises
Promise.all(emptyPromises)
.then(responses => {
// Check if all responses were successful
const allSuccessful = responses.every(response => response.ok);
if (allSuccessful) {
// Clear the current files array and re-render
currentFiles = [];
renderFiles(currentFiles);
// Close the modal
bootstrap.Modal.getInstance(document.getElementById('emptyTrashModal')).hide();
// Refresh the files list to ensure we have the latest state
fetchFiles();
} else {
console.error('Failed to empty trash in some rooms');
}
})
.catch(error => {
console.error('Error emptying trash:', error);
});
});
</script>
<style>
.file-name-ellipsis {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
vertical-align: bottom;
}
@media (min-width: 992px) {
.file-name-ellipsis { max-width: 180px; }
}
.file-action-btn {
min-width: 32px;
min-height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 5px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s;
}
.card:hover > .card-footer .file-action-btn {
opacity: 1;
pointer-events: auto;
}
.card-footer.bg-white.border-0.d-flex.justify-content-center.gap-2 {
min-height: 40px;
}
.card.file-card {
cursor: pointer;
}
.card.file-card .file-action-btn {
cursor: pointer;
}
#fileGrid.table-mode {
padding: 0;
}
#fileGrid.table-mode table {
width: 100%;
border-collapse: collapse;
background: #fff;
}
#fileGrid.table-mode th, #fileGrid.table-mode td {
padding: 0.5rem 1rem;
border-bottom: 1px solid #e9ecef;
text-align: left;
font-size: 0.95rem;
vertical-align: middle;
}
#fileGrid.table-mode th {
background: #f8f9fa;
color: #6c757d;
font-weight: 500;
}
#fileGrid.table-mode tr:hover td {
background-color: rgba(22, 118, 123, 0.08);
transition: background 0.15s;
}
#fileGrid.table-mode .file-icon {
width: 40px;
text-align: center;
}
#fileGrid.table-mode .file-actions {
min-width: 90px;
text-align: right;
}
#fileGrid.table-mode .file-action-btn {
opacity: 1;
pointer-events: auto;
min-width: 28px;
min-height: 28px;
font-size: 0.875rem;
margin-left: 0.25rem;
}
/* Disable text selection for file grid and table rows/cards */
#fileGrid, #fileGrid * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
#fileGrid .card.file-card {
cursor: pointer;
}
#fileGrid.table-mode tr {
cursor: pointer;
}
</style>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show More