diff --git a/retoors/helpers/email_sender.py b/retoors/helpers/email_sender.py index 69898aa..48f23f1 100644 --- a/retoors/helpers/email_sender.py +++ b/retoors/helpers/email_sender.py @@ -1,4 +1,6 @@ import aiosmtplib +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText from email.message import EmailMessage from aiohttp import web import logging @@ -24,11 +26,12 @@ async def send_email(app: web.Application, recipient_email: str, subject: str, b logger.error("SMTP host or sender email not configured. Cannot send email.") return - msg = EmailMessage() + msg = MIMEMultipart('alternative') msg["From"] = smtp_sender_email msg["To"] = recipient_email msg["Subject"] = subject - msg.set_content(body) + html_part = MIMEText(body, 'html') + msg.attach(html_part) try: await aiosmtplib.send( diff --git a/retoors/main.py b/retoors/main.py index fd6a487..581aceb 100644 --- a/retoors/main.py +++ b/retoors/main.py @@ -1,3 +1,5 @@ + +import argparse from aiohttp import web import aiohttp_jinja2 import jinja2 @@ -20,7 +22,7 @@ async def setup_services(app: web.Application): data_path = base_path.parent / "data" app["user_service"] = UserService(use_isolated_storage=True) app["config_service"] = ConfigService(data_path / "config.json") - app["file_service"] = FileService(data_path / "user_files", data_path / "users.json") + app["file_service"] = FileService(data_path / "user_files", app["user_service"]) # Setup aiojobs scheduler app["scheduler"] = aiojobs.Scheduler() @@ -67,9 +69,15 @@ def create_app(): return app + + def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--port', type=int, default=8080) + parser.add_argument('--hostname', default='0.0.0.0') + args = parser.parse_args() app = create_app() - web.run_app(app) + web.run_app(app, host=args.hostname, port=args.port) if __name__ == "__main__": diff --git a/retoors/routes.py b/retoors/routes.py index 0bccfff..253fde7 100644 --- a/retoors/routes.py +++ b/retoors/routes.py @@ -3,7 +3,8 @@ from .views.site import SiteView, OrderView, FileBrowserView, UserManagementView from .views.upload import UploadView from .views.migrate import MigrateView from .views.admin import get_users, add_user, update_user_quota, delete_user, get_user_details, delete_team - +from .views.editor import FileEditorView, FileContentView +from .views.viewer import ViewerView def setup_routes(app): app.router.add_view("/login", LoginView, name="login") @@ -21,6 +22,12 @@ def setup_routes(app): app.router.add_view("/order", OrderView, name="order") app.router.add_view("/terms", SiteView, name="terms") app.router.add_view("/privacy", SiteView, name="privacy") + app.router.add_view("/cookies", SiteView, name="cookies") + app.router.add_view("/impressum", SiteView, name="impressum") + app.router.add_view("/user_rights", SiteView, name="user_rights") + app.router.add_view("/aup", SiteView, name="aup") + app.router.add_view("/sla", SiteView, name="sla") + app.router.add_view("/compliance", SiteView, name="compliance") app.router.add_view("/shared", SiteView, name="shared") app.router.add_view("/recent", SiteView, name="recent") app.router.add_view("/favorites", SiteView, name="favorites") @@ -40,6 +47,10 @@ def setup_routes(app): app.router.add_post("/files/share_multiple", FileBrowserView, name="share_multiple_items") app.router.add_get("/shared_file/{share_id}", FileBrowserView.shared_file_handler, name="shared_file") app.router.add_get("/shared_file/{share_id}/download", FileBrowserView.download_shared_file_handler, name="download_shared_file") + app.router.add_view("/editor", FileEditorView, name="file_editor") + app.router.add_view("/viewer", ViewerView, name="file_viewer") + app.router.add_get("/api/file/content", FileContentView, name="get_file_content") + app.router.add_post("/api/file/save", FileEditorView, name="save_file_content") # Admin API routes for user and team management app.router.add_get("/api/users", get_users, name="api_get_users") diff --git a/retoors/services/file_service.py b/retoors/services/file_service.py index 0ea6729..f820533 100644 --- a/retoors/services/file_service.py +++ b/retoors/services/file_service.py @@ -137,6 +137,63 @@ class FileService: logger.info(f"download_file: Successfully read file: {file_path}") return content, Path(file_path).name + async def read_file_content_binary(self, user_email: str, file_path: str) -> str | None: + """Reads file content as text for editing.""" + metadata = await self._load_metadata(user_email) + if file_path not in metadata or metadata[file_path]["type"] != "file": + logger.warning(f"read_file_content: File not found in metadata: {file_path}") + return None + item_meta = metadata[file_path] + blob_loc = item_meta["blob_location"] + blob_path = self.drives_dir / blob_loc["drive"] / blob_loc["path"] + if not blob_path.exists(): + logger.warning(f"read_file_content: Blob not found: {blob_path}") + return None + try: + async with aiofiles.open(blob_path, 'rb') as f: + content = await f.read() + logger.info(f"read_file_content: Successfully read file: {file_path}") + return content + except UnicodeDecodeError: + logger.warning(f"read_file_content: File is not a text file: {file_path}") + return None + + + + + async def read_file_content(self, user_email: str, file_path: str) -> str | None: + """Reads file content as text for editing.""" + metadata = await self._load_metadata(user_email) + if file_path not in metadata or metadata[file_path]["type"] != "file": + logger.warning(f"read_file_content: File not found in metadata: {file_path}") + return None + item_meta = metadata[file_path] + blob_loc = item_meta["blob_location"] + blob_path = self.drives_dir / blob_loc["drive"] / blob_loc["path"] + if not blob_path.exists(): + logger.warning(f"read_file_content: Blob not found: {blob_path}") + return None + try: + async with aiofiles.open(blob_path, 'r', encoding='utf-8') as f: + content = await f.read() + logger.info(f"read_file_content: Successfully read file: {file_path}") + return content + except UnicodeDecodeError: + logger.warning(f"read_file_content: File is not a text file: {file_path}") + return None + + async def save_file_content(self, user_email: str, file_path: str, content: str) -> bool: + """Saves file content from editor.""" + try: + content_bytes = content.encode('utf-8') + success = await self.upload_file(user_email, file_path, content_bytes) + if success: + logger.info(f"save_file_content: Successfully saved file: {file_path}") + return success + except Exception as e: + logger.error(f"save_file_content: Error saving file {file_path}: {e}") + return False + async def delete_item(self, user_email: str, item_path: str) -> bool: """Deletes a file or folder for the user.""" metadata = await self._load_metadata(user_email) diff --git a/retoors/services/storage_service.py b/retoors/services/storage_service.py index 78916bb..546dc53 100644 --- a/retoors/services/storage_service.py +++ b/retoors/services/storage_service.py @@ -4,6 +4,7 @@ import hashlib import aiofiles from pathlib import Path from typing import Any, Dict, List, Optional +from .lock_manager import get_lock_manager class StorageService: @@ -11,6 +12,7 @@ class StorageService: def __init__(self, base_path: str = "data/user"): self.base_path = Path(base_path) self.base_path.mkdir(parents=True, exist_ok=True) + self.lock_manager = get_lock_manager() def _hash(self, value: str) -> str: return hashlib.sha256(value.encode()).hexdigest() @@ -43,8 +45,10 @@ class StorageService: file_path.parent.mkdir(parents=True, exist_ok=True) - async with aiofiles.open(file_path, 'w') as f: - await f.write(json.dumps(data, indent=2)) + lock = await self.lock_manager.get_lock(str(file_path)) + async with lock: + async with aiofiles.open(file_path, 'w') as f: + await f.write(json.dumps(data, indent=2)) return True @@ -58,14 +62,16 @@ class StorageService: if not file_path.exists(): return None - async with aiofiles.open(file_path, 'r') as f: - content = await f.read() - if not content: - return {} - try: - return json.loads(content) - except json.JSONDecodeError: - return {} + lock = await self.lock_manager.get_lock(str(file_path)) + async with lock: + async with aiofiles.open(file_path, 'r') as f: + content = await f.read() + if not content: + return {} + try: + return json.loads(content) + except json.JSONDecodeError: + return {} async def delete(self, user_email: str, identifier: str) -> bool: user_base = self._get_user_base_path(user_email) @@ -75,8 +81,11 @@ class StorageService: raise ValueError("Invalid path: directory traversal detected") if file_path.exists(): - file_path.unlink() - return True + lock = await self.lock_manager.get_lock(str(file_path)) + async with lock: + if file_path.exists(): + file_path.unlink() + return True return False @@ -98,9 +107,11 @@ class StorageService: results = [] for json_file in user_base.rglob("*.json"): if self._validate_path(json_file, user_base): - async with aiofiles.open(json_file, 'r') as f: - content = await f.read() - results.append(json.loads(content)) + lock = await self.lock_manager.get_lock(str(json_file)) + async with lock: + async with aiofiles.open(json_file, 'r') as f: + content = await f.read() + results.append(json.loads(content)) return results @@ -122,6 +133,7 @@ class UserStorageManager: def __init__(self): self.storage = StorageService() + self.lock_manager = get_lock_manager() async def save_user(self, user_email: str, user_data: Dict[str, Any]) -> bool: return await self.storage.save(user_email, user_email, user_data) @@ -146,11 +158,13 @@ class UserStorageManager: if user_dir.is_dir(): user_files = list(user_dir.rglob("*.json")) for user_file in user_files: - async with aiofiles.open(user_file, 'r') as f: - content = await f.read() - user_data = json.loads(content) - if user_data.get('parent_email') == parent_email: - all_users.append(user_data) + lock = await self.lock_manager.get_lock(str(user_file)) + async with lock: + async with aiofiles.open(user_file, 'r') as f: + content = await f.read() + user_data = json.loads(content) + if user_data.get('parent_email') == parent_email: + all_users.append(user_data) return all_users @@ -165,8 +179,10 @@ class UserStorageManager: if user_dir.is_dir(): user_files = list(user_dir.rglob("*.json")) for user_file in user_files: - async with aiofiles.open(user_file, 'r') as f: - content = await f.read() - all_users.append(json.loads(content)) + lock = await self.lock_manager.get_lock(str(user_file)) + async with lock: + async with aiofiles.open(user_file, 'r') as f: + content = await f.read() + all_users.append(json.loads(content)) return all_users diff --git a/retoors/services/user_service.py b/retoors/services/user_service.py index e63e682..3bc99ce 100644 --- a/retoors/services/user_service.py +++ b/retoors/services/user_service.py @@ -5,11 +5,13 @@ import bcrypt import secrets import datetime from .storage_service import UserStorageManager +from .lock_manager import get_lock_manager class UserService: def __init__(self, users_path: Path = None, use_isolated_storage: bool = True): self.use_isolated_storage = use_isolated_storage + self.lock_manager = get_lock_manager() if use_isolated_storage: self._storage_manager = UserStorageManager() @@ -27,11 +29,13 @@ class UserService: except json.JSONDecodeError: return [] - def _save_users(self): + async def _save_users(self): if self.use_isolated_storage: return - with open(self._users_path, "w") as f: - json.dump(self._users, f, indent=4) + lock = await self.lock_manager.get_lock(str(self._users_path)) + async with lock: + with open(self._users_path, "w") as f: + json.dump(self._users, f, indent=4) async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]: if self.use_isolated_storage: @@ -80,7 +84,7 @@ class UserService: await self._storage_manager.save_user(email, user) else: self._users.append(user) - self._save_users() + await self._save_users() return user @@ -98,7 +102,7 @@ class UserService: if self.use_isolated_storage: await self._storage_manager.save_user(email, user) else: - self._save_users() + await self._save_users() return user @@ -109,7 +113,7 @@ class UserService: initial_len = len(self._users) self._users = [user for user in self._users if user["email"] != email] if len(self._users) < initial_len: - self._save_users() + await self._save_users() return True return False @@ -126,7 +130,7 @@ class UserService: self._users = [user for user in self._users if user.get("parent_email") != parent_email] deleted_count = initial_len - len(self._users) if deleted_count > 0: - self._save_users() + await self._save_users() return deleted_count @@ -160,7 +164,7 @@ class UserService: if self.use_isolated_storage: await self._storage_manager.save_user(email, user) else: - self._save_users() + await self._save_users() return token @@ -192,7 +196,7 @@ class UserService: if self.use_isolated_storage: await self._storage_manager.save_user(email, user) else: - self._save_users() + await self._save_users() return True @@ -206,4 +210,4 @@ class UserService: if self.use_isolated_storage: await self._storage_manager.save_user(email, user) else: - self._save_users() + await self._save_users() diff --git a/retoors/static/css/base.css b/retoors/static/css/base.css index 0d8e593..bb2cb4f 100644 --- a/retoors/static/css/base.css +++ b/retoors/static/css/base.css @@ -1,64 +1,72 @@ :root { - --primary-color: #4A90E2; /* Blue from image */ - --accent-color: #50E3C2; /* Greenish-blue from image */ - --secondary-color: #B8C2CC; /* Light grey-blue */ - --background-color: #F8F8F8; /* Very light grey background */ - --text-color: #333333; /* Darker text */ - --light-text-color: #666666; /* Lighter text for descriptions */ - --border-color: #E0E0E0; /* Light grey border */ + --primary-color: #0066FF; /* Vibrant blue */ + --accent-color: #00D4FF; /* Bright cyan accent */ + --secondary-color: #F7FAFC; /* Very light blue-grey */ + --background-color: #FFFFFF; /* Pure white background */ + --text-color: #1A202C; /* Dark blue-grey */ + --light-text-color: #718096; /* Medium grey for descriptions */ + --border-color: #E2E8F0; /* Light grey border */ --card-background: #FFFFFF; /* White for cards */ - --shadow-color: rgba(0, 0, 0, 0.08); /* Subtle shadow */ + --shadow-color: rgba(0, 0, 0, 0.05); /* Very subtle shadow */ - /* Button specific variables, using accent for primary CTAs */ + /* Button specific variables */ --btn-primary-bg: var(--primary-color); --btn-primary-text: #FFFFFF; - --btn-primary-hover-bg: #3A7BD5; /* Slightly darker blue */ - --btn-secondary-bg: #E0E0E0; /* Light grey for secondary buttons */ + --btn-primary-hover-bg: #0052CC; /* Darker blue */ + --btn-secondary-bg: #EDF2F7; /* Light grey */ --btn-secondary-text: var(--text-color); - --btn-secondary-hover-bg: #BDBDBD; /* Darker grey for secondary hover */ + --btn-secondary-hover-bg: #E2E8F0; /* Darker grey */ --btn-outline-border: var(--primary-color); --btn-outline-text: var(--primary-color); - --btn-outline-hover-bg: rgba(74, 144, 226, 0.1); /* Light blue hover */ + --btn-outline-hover-bg: rgba(0, 102, 255, 0.05); /* Very light blue hover */ } html, body { height: 100%; margin: 0; - font-family: 'Roboto', sans-serif; /* Using a more modern font */ + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; background-color: var(--background-color); color: var(--text-color); display: flex; flex-direction: column; - line-height: 1.6; /* Improve readability */ + line-height: 1.6; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + font-size: 16px; } /* General typography */ h1 { - font-size: 2.2rem; /* Slightly larger heading */ + font-size: 3.5rem; font-weight: 700; - margin-bottom: 1rem; + margin-bottom: 1.5rem; color: var(--text-color); + line-height: 1.2; + letter-spacing: -0.02em; } h2 { - font-size: 2.2rem; + font-size: 2.5rem; font-weight: 700; - margin-bottom: 0.8rem; + margin-bottom: 1rem; color: var(--text-color); + line-height: 1.3; + letter-spacing: -0.01em; } h3 { - font-size: 1.8rem; - font-weight: 500; - margin-bottom: 0.6rem; + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 0.75rem; color: var(--text-color); + line-height: 1.4; } p { margin-bottom: 1rem; color: var(--light-text-color); + font-size: 1.125rem; + line-height: 1.7; } /* Card-like styling for sections */ @@ -147,96 +155,100 @@ p { } /* Header and Navigation */ -header { - background-color: var(--card-background); /* White background for header */ - box-shadow: 0 2px 4px var(--shadow-color); - padding: 15px 20px; +.site-header { + background-color: #FFFFFF; + border-bottom: 1px solid var(--border-color); position: sticky; top: 0; z-index: 1000; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } -nav { +.site-nav { + padding: 0 20px; +} + +.nav-container { display: flex; + flex-direction: row; justify-content: space-between; align-items: center; max-width: 1200px; margin: 0 auto; + min-height: 70px; + gap: 2rem; } -nav .logo { +.brand { + text-decoration: none; display: flex; align-items: center; - text-decoration: none; - color: var(--text-color); + flex-shrink: 0; +} + +.brand-text { font-size: 1.5rem; font-weight: 700; + color: var(--text-color); + letter-spacing: -0.01em; } -nav .logo img { - height: 30px; /* Adjust logo size */ - margin-right: 10px; -} - -nav .nav-links { +.nav-menu { display: flex; + flex-direction: row; list-style: none; margin: 0; padding: 0; - gap: 30px; + gap: 2rem; + align-items: center; } -nav .nav-links a { +.nav-menu li { + list-style: none; +} + +.nav-menu a { color: var(--text-color); text-decoration: none; font-weight: 500; - transition: color 0.3s ease; + font-size: 1rem; + transition: color 0.2s ease; + white-space: nowrap; } -nav .nav-links a:hover { +.nav-menu a:hover { color: var(--primary-color); } -.btn-primary-nav { - background-color: var(--primary-color); - color: white; - padding: 10px 20px; - border-radius: 5px; +.nav-actions { + display: flex; + flex-direction: row; + align-items: center; + gap: 1rem; + flex-shrink: 0; +} + +.nav-link { + color: var(--text-color); text-decoration: none; font-weight: 500; - transition: background-color 0.3s ease; + font-size: 1rem; + transition: color 0.2s ease; + white-space: nowrap; } -.btn-primary-nav:hover { - background-color: var(--btn-primary-hover-bg); -} - -.btn-outline-nav { - background-color: transparent; +.nav-link:hover { color: var(--primary-color); - border: 1px solid var(--primary-color); - padding: 10px 20px; - border-radius: 5px; - text-decoration: none; - font-weight: 500; - transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease; -} - -.btn-outline-nav:hover { - background-color: rgba(74, 144, 226, 0.1); - color: var(--primary-color); - border-color: var(--primary-color); } /* Main content area */ main { - flex-grow: 1; /* Allow main content to take available space */ + flex-grow: 1; width: 100%; - max-width: 1200px; /* Match header max-width */ - margin: 0 auto; /* Center the main content */ - padding: 40px 20px; /* Add padding */ + margin: 0; + padding: 0; box-sizing: border-box; - background-color: var(--background-color); /* Light background for main content */ + background-color: var(--background-color); } /* Form specific styles */ @@ -244,17 +256,17 @@ main { max-width: 450px; margin: 50px auto; background-color: var(--card-background); - border-radius: 12px; /* Slightly more rounded corners */ - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); /* Softer, more prominent shadow */ - padding: 3rem; /* Increased padding */ + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + padding: 2.5rem; text-align: left; } .form-container h2 { text-align: center; - margin-bottom: 35px; /* Increased margin */ + margin-bottom: 2rem; color: var(--text-color); - font-size: 2.2rem; /* Slightly larger heading */ + font-size: 2rem; font-weight: 700; } @@ -309,16 +321,17 @@ main { .btn-small, .btn-danger { display: inline-block; - padding: 0.6rem 1.2rem; - font-size: 0.9rem; - font-weight: 500; + padding: 0.65rem 1.5rem; + font-size: 0.95rem; + font-weight: 600; text-align: center; text-decoration: none; - border-radius: 4px; + border-radius: 6px; cursor: pointer; transition: all 0.2s ease; - border: 1px solid transparent; + border: 2px solid transparent; box-sizing: border-box; + white-space: nowrap; } .btn-primary { @@ -330,6 +343,8 @@ main { .btn-primary:hover { background-color: var(--btn-primary-hover-bg); border-color: var(--btn-primary-hover-bg); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 102, 255, 0.2); } .btn-outline { @@ -342,6 +357,7 @@ main { background-color: var(--btn-outline-hover-bg); color: var(--btn-outline-text); border-color: var(--btn-outline-border); + transform: translateY(-1px); } .btn-small { @@ -372,28 +388,81 @@ main { text-align: center; } -/* Responsive adjustments for base.css */ +/* Responsive adjustments */ +@media (max-width: 992px) { + .nav-menu { + gap: 1.25rem; + } + + .nav-container { + gap: 1rem; + } +} + @media (max-width: 768px) { - nav { + .site-nav { + padding: 10px 15px; + } + + .nav-container { + flex-wrap: wrap; + min-height: auto; + padding: 10px 0; + gap: 1rem; + } + + .brand { + flex: 1 1 100%; + justify-content: center; + order: 1; + margin-bottom: 10px; + } + + .nav-menu { + flex: 1 1 100%; + flex-direction: row; flex-wrap: wrap; justify-content: center; - gap: 15px; - } - nav .logo { - width: 100%; - justify-content: center; + gap: 1rem; + order: 2; margin-bottom: 10px; } - nav .nav-links { - width: 100%; - justify-content: center; - margin-bottom: 10px; + + .nav-menu a { + font-size: 0.9rem; } - .btn-primary-nav { + + .nav-actions { + flex: 1 1 100%; + justify-content: center; + order: 3; + gap: 0.75rem; + } +} + +@media (max-width: 480px) { + .brand-text { + font-size: 1.25rem; + } + + .nav-menu { + gap: 0.75rem; + } + + .nav-menu a { + font-size: 0.85rem; + } + + .nav-actions { + flex-direction: column; + gap: 0.5rem; width: 100%; + } + + .nav-actions .btn-primary, + .nav-actions .btn-outline { + width: 100%; + max-width: 280px; text-align: center; } - main { - padding: 20px 15px; - } } \ No newline at end of file diff --git a/retoors/static/css/components/content_pages.css b/retoors/static/css/components/content_pages.css index 51b596a..3da65ac 100644 --- a/retoors/static/css/components/content_pages.css +++ b/retoors/static/css/components/content_pages.css @@ -1,3 +1,72 @@ +/* Content pages main wrapper */ +main.content-page { + max-width: 1200px; + margin: 0 auto; + padding: 40px 20px; +} + +/* Content sections for legal and information pages */ +.content-section { + max-width: 900px; + margin: 0 auto; + padding: 60px 20px; +} + +.content-section h1 { + font-size: 2.5rem; + margin-bottom: 1.5rem; + color: var(--text-color); +} + +.content-section h2 { + font-size: 1.75rem; + margin-top: 2.5rem; + margin-bottom: 1rem; + color: var(--text-color); +} + +.content-section h3 { + font-size: 1.25rem; + margin-top: 2rem; + margin-bottom: 0.75rem; + color: var(--text-color); +} + +.content-section p { + margin-bottom: 1.25rem; + line-height: 1.7; +} + +.content-section ul { + margin-bottom: 1.5rem; + padding-left: 2rem; +} + +.content-section li { + margin-bottom: 0.5rem; + line-height: 1.6; +} + +.content-section table { + width: 100%; + border-collapse: collapse; + margin: 2rem 0; + background-color: var(--card-background); +} + +.content-section table th, +.content-section table td { + padding: 1rem; + border: 1px solid var(--border-color); + text-align: left; +} + +.content-section table th { + background-color: var(--secondary-color); + font-weight: 600; + color: var(--text-color); +} + /* General styles for hero sections on content pages */ .hero-intro { padding: 20px; diff --git a/retoors/static/css/components/dashboard.css b/retoors/static/css/components/dashboard.css index b0b890a..ddaefa1 100644 --- a/retoors/static/css/components/dashboard.css +++ b/retoors/static/css/components/dashboard.css @@ -10,15 +10,25 @@ } .dashboard-sidebar { - flex: 0 0 250px; /* Fixed width sidebar */ + flex: 0 0 250px; background-color: var(--card-background); border-radius: 8px; box-shadow: 0 4px 12px var(--shadow-color); padding: 20px; - position: sticky; /* Make sidebar sticky */ - top: 100px; /* Adjust based on header height */ - max-height: calc(100vh - 120px); /* Adjust based on header/footer */ - overflow-y: auto; + position: sticky; + top: 100px; + max-height: calc(100vh - 120px); + overflow-y: hidden; + overflow-x: hidden; +} + +.dashboard-sidebar::-webkit-scrollbar { + display: none; +} + +.dashboard-sidebar { + -ms-overflow-style: none; + scrollbar-width: none; } .sidebar-menu ul { @@ -162,6 +172,7 @@ width: 100%; border-collapse: collapse; margin-top: 20px; + table-layout: fixed; } .file-list-table th, @@ -181,6 +192,9 @@ .file-list-table td { color: var(--light-text-color); + word-break: break-all; + overflow-wrap: break-word; + max-width: 300px; } .file-list-table tr:last-child td { @@ -200,6 +214,43 @@ color: var(--light-text-color); } +.file-list-table a { + word-break: break-all; + overflow-wrap: break-word; +} + +.file-list-table th:first-child, +.file-list-table td:first-child { + width: 40px; + max-width: 40px; +} + +.file-list-table th:nth-child(2), +.file-list-table td:nth-child(2) { + width: 40%; + min-width: 150px; +} + +.file-list-table th:nth-child(3), +.file-list-table td:nth-child(3) { + width: 20%; +} + +.file-list-table th:nth-child(4), +.file-list-table td:nth-child(4) { + width: 15%; +} + +.file-list-table th:nth-child(5), +.file-list-table td:nth-child(5) { + width: 10%; +} + +.file-list-table th:last-child, +.file-list-table td:last-child { + width: 15%; +} + /* Responsive adjustments */ @media (max-width: 992px) { .dashboard-layout { @@ -249,4 +300,8 @@ padding: 10px 12px; font-size: 0.85rem; } + + .file-list-table td { + max-width: 150px; + } } \ No newline at end of file diff --git a/retoors/static/css/components/file_browser.css b/retoors/static/css/components/file_browser.css index 0f9840b..d38c830 100644 --- a/retoors/static/css/components/file_browser.css +++ b/retoors/static/css/components/file_browser.css @@ -1,5 +1,20 @@ +.breadcrumb { + margin-bottom: 20px; + font-size: 0.9rem; + color: var(--light-text-color); +} + +.breadcrumb a { + color: var(--accent-color); + text-decoration: none; +} + +.breadcrumb a:hover { + text-decoration: underline; +} + .modal { - display: none; + display: none !important; position: fixed; z-index: 1000; left: 0; @@ -10,6 +25,10 @@ background-color: rgba(0, 0, 0, 0.5); } +.modal.show { + display: block !important; +} + .modal-content { background-color: var(--card-background); margin: 10% auto; @@ -161,6 +180,7 @@ .file-list-table table { width: 100%; border-collapse: collapse; + table-layout: fixed; } .file-list-table th { @@ -177,6 +197,9 @@ padding: 12px 15px; border-bottom: 1px solid var(--border-color); color: var(--text-color); + word-break: break-all; + overflow-wrap: break-word; + max-width: 300px; } .file-list-table tr:hover { @@ -197,12 +220,46 @@ .file-list-table a { color: var(--accent-color); text-decoration: none; + word-break: break-all; + overflow-wrap: break-word; } .file-list-table a:hover { text-decoration: underline; } +.file-list-table th:first-child, +.file-list-table td:first-child { + width: 40px; + max-width: 40px; +} + +.file-list-table th:nth-child(2), +.file-list-table td:nth-child(2) { + width: 40%; + min-width: 150px; +} + +.file-list-table th:nth-child(3), +.file-list-table td:nth-child(3) { + width: 20%; +} + +.file-list-table th:nth-child(4), +.file-list-table td:nth-child(4) { + width: 15%; +} + +.file-list-table th:nth-child(5), +.file-list-table td:nth-child(5) { + width: 10%; +} + +.file-list-table th:last-child, +.file-list-table td:last-child { + width: 15%; +} + @media (max-width: 768px) { .modal-content { width: 95%; @@ -227,6 +284,10 @@ padding: 8px 10px; } + .file-list-table td { + max-width: 150px; + } + .dashboard-actions { flex-wrap: wrap; } diff --git a/retoors/static/css/components/footer.css b/retoors/static/css/components/footer.css index f89b4ca..089318a 100644 --- a/retoors/static/css/components/footer.css +++ b/retoors/static/css/components/footer.css @@ -1,14 +1,70 @@ footer { - background-color: var(--card-background); /* Consistent with header */ - color: var(--light-text-color); /* Subtle text color */ - padding: 1.5rem 2rem; - text-align: center; - border-top: 1px solid var(--border-color); /* Separator from content */ - margin-top: auto; /* Push footer to the bottom */ - box-shadow: 0 -2px 4px var(--shadow-color); /* Subtle shadow upwards */ + background-color: var(--card-background); + color: var(--light-text-color); + padding: 2rem 2rem 1rem 2rem; + border-top: 1px solid var(--border-color); + margin-top: auto; + box-shadow: 0 -2px 4px var(--shadow-color); } -footer p { - margin: 0; /* Remove default paragraph margin */ - font-size: 0.9rem; +.footer-content { + display: flex; + justify-content: space-around; + max-width: 1200px; + margin: 0 auto 2rem auto; + gap: 2rem; +} + +.footer-section { + flex: 1; +} + +.footer-section h4 { + color: var(--text-color); + font-size: 0.95rem; + margin: 0 0 1rem 0; + font-weight: 600; +} + +.footer-section ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-section ul li { + margin-bottom: 0.5rem; +} + +.footer-section ul li a { + color: var(--light-text-color); + text-decoration: none; + font-size: 0.85rem; + transition: color 0.2s ease; +} + +.footer-section ul li a:hover { + color: var(--accent-color); +} + +.footer-bottom { + text-align: center; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.footer-bottom p { + margin: 0; + font-size: 0.85rem; +} + +@media (max-width: 768px) { + .footer-content { + flex-direction: column; + gap: 1.5rem; + } + + .footer-section { + text-align: center; + } } diff --git a/retoors/static/css/components/index.css b/retoors/static/css/components/index.css index 6b81c55..7645184 100644 --- a/retoors/static/css/components/index.css +++ b/retoors/static/css/components/index.css @@ -1,135 +1,260 @@ -/* Styles for the Homepage (index.html) */ - +/* Hero Section */ .hero-section { text-align: center; - padding: 60px 20px; - /* Removed background-color and box-shadow to match image */ - margin-bottom: 40px; - position: relative; - overflow: hidden; + padding: 100px 20px 80px; + background: linear-gradient(135deg, #F7FAFC 0%, #FFFFFF 100%); + margin-bottom: 0; +} + +.hero-content { + max-width: 900px; + margin: 0 auto; } .hero-section h1 { - font-size: 3.5rem; + font-size: 3.75rem; font-weight: 700; color: var(--text-color); - margin-bottom: 0.5rem; /* Adjusted margin to match image */ - line-height: 1.2; + margin-bottom: 1.5rem; + line-height: 1.15; + letter-spacing: -0.03em; } -.hero-section p { - font-size: 1.3rem; +.hero-subtitle { + font-size: 1.25rem; color: var(--light-text-color); - max-width: 800px; - margin: 0 auto 2.5rem auto; - line-height: 1.5; + max-width: 700px; + margin: 0 auto 2.5rem; + line-height: 1.6; } -.benefits-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); - gap: 30px; - max-width: 1200px; - margin: 40px auto; /* Adjusted margin to separate from text */ -} - -.benefit-card { - background-color: var(--card-background); - padding: 30px; - border-radius: 8px; - box-shadow: 0 4px 15px var(--shadow-color); - text-align: center; - transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out; +.hero-ctas { display: flex; - flex-direction: column; - align-items: center; + gap: 1rem; justify-content: center; - min-height: 250px; /* Ensure cards have a consistent height */ + margin-bottom: 1.5rem; } -.benefit-card:hover { - transform: translateY(-5px); - box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12); +.hero-btn { + padding: 1rem 2rem; + font-size: 1.125rem; + font-weight: 600; + border-radius: 8px; + text-decoration: none; + transition: all 0.3s ease; } -.benefit-card img.icon { - width: 60px; - height: 60px; - margin-bottom: 20px; - /* Icons in the image are colored, not inheriting text color */ +.hero-subtext { + font-size: 0.875rem; + color: var(--light-text-color); + margin: 0; } -.benefit-card h3 { - font-size: 1.5rem; +/* Features Section */ +.features-section { + padding: 80px 20px; + background-color: #FFFFFF; +} + +.features-header { + text-align: center; + max-width: 700px; + margin: 0 auto 60px; +} + +.features-header h2 { + font-size: 2.5rem; + margin-bottom: 1rem; +} + +.features-header p { + font-size: 1.125rem; + color: var(--light-text-color); +} + +.features-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 2.5rem; + max-width: 1200px; + margin: 0 auto; +} + +.feature-card { + text-align: center; + padding: 2rem; +} + +.feature-icon { + color: var(--primary-color); + margin-bottom: 1.5rem; + display: inline-block; +} + +.feature-card h3 { + font-size: 1.25rem; + font-weight: 600; + margin-bottom: 0.75rem; color: var(--text-color); - margin-bottom: 15px; } -.benefit-card p { +.feature-card p { font-size: 1rem; color: var(--light-text-color); line-height: 1.6; } -/* Specific card colors from the image */ -.family-card { - background-color: #D0E6F0; /* Light blue */ +/* Use Cases Section */ +.use-cases-section { + padding: 80px 20px; + background: linear-gradient(135deg, #F7FAFC 0%, #EDF2F7 100%); + text-align: center; } -.professional-card { - background-color: #F0E0D0; /* Light orange */ +.use-cases-section h2 { + font-size: 2.5rem; + margin-bottom: 3rem; + color: var(--text-color); } -.student-card { - background-color: #D0F0D0; /* Light green */ +.use-cases-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 2rem; + max-width: 1200px; + margin: 0 auto; } -.find-plan-btn { - margin-top: 40px; - padding: 15px 30px; - font-size: 1.1rem; +.use-case-card { + background-color: #FFFFFF; + padding: 2.5rem; + border-radius: 12px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.use-case-card:hover { + transform: translateY(-8px); + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12); +} + +.use-case-icon { + width: 64px; + height: 64px; + margin-bottom: 1.5rem; +} + +.use-case-card h3 { + font-size: 1.5rem; font-weight: 600; - border-radius: 5px; - background-color: #4A90E2; /* Blue from the image */ + margin-bottom: 1rem; + color: var(--text-color); +} + +.use-case-card p { + font-size: 1rem; + color: var(--light-text-color); + line-height: 1.6; +} + +/* CTA Section */ +.cta-section { + padding: 100px 20px; + background: linear-gradient(135deg, var(--primary-color) 0%, #0052CC 100%); + text-align: center; color: white; +} + +.cta-content { + max-width: 700px; + margin: 0 auto; +} + +.cta-section h2 { + font-size: 2.75rem; + color: white; + margin-bottom: 1rem; +} + +.cta-section p { + font-size: 1.25rem; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 2.5rem; +} + +.cta-btn { + padding: 1rem 2.5rem; + font-size: 1.125rem; + font-weight: 600; + background-color: white; + color: var(--primary-color); + border-radius: 8px; text-decoration: none; display: inline-block; - transition: background-color 0.3s ease; + transition: transform 0.3s ease, box-shadow 0.3s ease; } -.find-plan-btn:hover { - background-color: #3A7BD5; /* Darker blue on hover */ +.cta-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); } -/* Responsive adjustments for index.css */ +/* Responsive Design */ @media (max-width: 992px) { .hero-section h1 { font-size: 3rem; } - .hero-section p { - font-size: 1.2rem; + + .features-grid { + grid-template-columns: repeat(2, 1fr); + } + + .features-header h2, + .use-cases-section h2, + .cta-section h2 { + font-size: 2rem; } } @media (max-width: 768px) { .hero-section { - padding: 40px 15px; + padding: 60px 20px 50px; } + .hero-section h1 { font-size: 2.5rem; } - .hero-section p { - font-size: 1rem; + + .hero-subtitle { + font-size: 1.125rem; } - .benefits-grid { - grid-template-columns: 1fr; /* Stack cards on small screens */ + + .hero-ctas { + flex-direction: column; + gap: 0.75rem; } - .benefit-card { - padding: 25px; - } - .find-plan-btn { + + .hero-btn { width: 100%; - box-sizing: border-box; + max-width: 300px; + } + + .features-grid { + grid-template-columns: 1fr; + gap: 2rem; + } + + .use-cases-grid { + grid-template-columns: 1fr; + } + + .features-section, + .use-cases-section { + padding: 60px 20px; + } + + .cta-section { + padding: 60px 20px; } } @@ -137,11 +262,14 @@ .hero-section h1 { font-size: 2rem; } - .hero-section p { - font-size: 0.9rem; + + .hero-subtitle { + font-size: 1rem; } - .hero-ctas .btn-primary, .hero-ctas .btn-outline { - width: 100%; - box-sizing: border-box; + + .features-header h2, + .use-cases-section h2, + .cta-section h2 { + font-size: 1.75rem; } } \ No newline at end of file diff --git a/retoors/static/js/components/upload.js b/retoors/static/js/components/upload.js index b81f027..0cd6ad8 100644 --- a/retoors/static/js/components/upload.js +++ b/retoors/static/js/components/upload.js @@ -1,9 +1,11 @@ export function showUploadModal() { - document.getElementById('upload-modal').style.display = 'block'; - // Clear previous selections and progress + const modal = document.getElementById('upload-modal'); + if (modal) { + modal.classList.add('show'); + } document.getElementById('selected-files-preview').innerHTML = ''; document.getElementById('upload-progress-container').innerHTML = ''; - document.getElementById('file-input-multiple').value = ''; // Clear selected files + document.getElementById('file-input-multiple').value = ''; document.getElementById('start-upload-btn').disabled = true; } @@ -13,6 +15,11 @@ document.addEventListener('DOMContentLoaded', () => { const startUploadBtn = document.getElementById('start-upload-btn'); const uploadProgressContainer = document.getElementById('upload-progress-container'); + if (!fileInput || !selectedFilesPreview || !startUploadBtn || !uploadProgressContainer) { + console.error('Upload elements not found'); + return; + } + let filesToUpload = []; fileInput.addEventListener('change', (event) => { @@ -55,62 +62,94 @@ document.addEventListener('DOMContentLoaded', () => { }); async function uploadFiles(files) { - startUploadBtn.disabled = true; // Disable button during upload - uploadProgressContainer.innerHTML = ''; // Clear previous progress + startUploadBtn.disabled = true; + uploadProgressContainer.innerHTML = ''; const currentPath = new URLSearchParams(window.location.search).get('path') || ''; + console.log('Uploading to directory:', currentPath || '(root)'); - for (const file of files) { - const formData = new FormData(); - formData.append('file', file); + let completedUploads = 0; + let totalFiles = files.length; + let hasErrors = false; - const progressBarContainer = document.createElement('div'); - progressBarContainer.className = 'progress-bar-container'; - progressBarContainer.innerHTML = ` -
${file.name}
-
-
-
-
0%
- `; - uploadProgressContainer.appendChild(progressBarContainer); + const uploadPromises = files.map(file => { + return new Promise((resolve, reject) => { + const formData = new FormData(); + formData.append('current_path', currentPath); + formData.append('file', file); - const xhr = new XMLHttpRequest(); - xhr.open('POST', `/files/upload?current_path=${encodeURIComponent(currentPath)}`, true); + const progressBarContainer = document.createElement('div'); + progressBarContainer.className = 'progress-bar-container'; + progressBarContainer.innerHTML = ` +
${file.name}
+
+
+
+
0%
+ `; + uploadProgressContainer.appendChild(progressBarContainer); - xhr.upload.addEventListener('progress', (event) => { - if (event.lengthComputable) { - const percent = (event.loaded / event.total) * 100; - document.getElementById(`progress-${file.name.replace(/\./g, '-')}`).style.width = `${percent}%`; - document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`).textContent = `${Math.round(percent)}%`; - } + const xhr = new XMLHttpRequest(); + xhr.open('POST', `/files/upload`, true); + + xhr.upload.addEventListener('progress', (event) => { + if (event.lengthComputable) { + const percent = (event.loaded / event.total) * 100; + const progressBar = document.getElementById(`progress-${file.name.replace(/\./g, '-')}`); + const progressText = document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`); + if (progressBar) progressBar.style.width = `${percent}%`; + if (progressText) progressText.textContent = `${Math.round(percent)}%`; + } + }); + + xhr.addEventListener('load', () => { + if (xhr.status === 200) { + console.log(`File ${file.name} uploaded successfully.`); + const progressBar = document.getElementById(`progress-${file.name.replace(/\./g, '-')}`); + const progressText = document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`); + if (progressBar) progressBar.style.width = '100%'; + if (progressText) progressText.textContent = '100% (Done)'; + resolve(); + } else { + console.error(`Error uploading ${file.name}: ${xhr.statusText}`); + const progressText = document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`); + const progressBar = document.getElementById(`progress-${file.name.replace(/\./g, '-')}`); + if (progressText) progressText.textContent = `Failed (${xhr.status})`; + if (progressBar) progressBar.style.backgroundColor = 'red'; + hasErrors = true; + reject(new Error(`Upload failed with status ${xhr.status}`)); + } + }); + + xhr.addEventListener('error', () => { + console.error(`Network error uploading ${file.name}.`); + const progressText = document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`); + const progressBar = document.getElementById(`progress-${file.name.replace(/\./g, '-')}`); + if (progressText) progressText.textContent = 'Network Error'; + if (progressBar) progressBar.style.backgroundColor = 'red'; + hasErrors = true; + reject(new Error('Network error')); + }); + + xhr.send(formData); }); + }); - xhr.addEventListener('load', () => { - if (xhr.status === 200) { - console.log(`File ${file.name} uploaded successfully.`); - // Update progress to 100% on completion - document.getElementById(`progress-${file.name.replace(/\./g, '-')}`).style.width = `100%`; - document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`).textContent = `100% (Done)`; + try { + await Promise.allSettled(uploadPromises); + + setTimeout(() => { + const currentUrl = new URL(window.location.href); + const pathParam = currentUrl.searchParams.get('path'); + if (pathParam) { + window.location.href = `/files?path=${encodeURIComponent(pathParam)}`; } else { - console.error(`Error uploading ${file.name}: ${xhr.statusText}`); - document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`).textContent = `Failed (${xhr.status})`; - document.getElementById(`progress-${file.name.replace(/\./g, '-')}`).style.backgroundColor = `red`; + window.location.href = '/files'; } - }); - - xhr.addEventListener('error', () => { - console.error(`Network error uploading ${file.name}.`); - document.getElementById(`progress-text-${file.name.replace(/\./g, '-')}`).textContent = `Network Error`; - document.getElementById(`progress-${file.name.replace(/\./g, '-')}`).style.backgroundColor = `red`; - }); - - xhr.send(formData); + }, 500); + } catch (error) { + console.error('Error during upload:', error); + startUploadBtn.disabled = false; } - // After all files are sent, refresh the page to show new files - // A small delay to allow server to process and update file list - setTimeout(() => { - window.location.reload(); - }, 1000); } }); diff --git a/retoors/static/js/main.js b/retoors/static/js/main.js index e03b3ce..37469d8 100644 --- a/retoors/static/js/main.js +++ b/retoors/static/js/main.js @@ -88,17 +88,23 @@ document.addEventListener('DOMContentLoaded', () => { // Helper functions for modals function showNewFolderModal() { - document.getElementById('new-folder-modal').style.display = 'block'; + const modal = document.getElementById('new-folder-modal'); + if (modal) { + modal.classList.add('show'); + } } function closeModal(modalId) { - document.getElementById(modalId).style.display = 'none'; + const modal = document.getElementById(modalId); + if (modal) { + modal.classList.remove('show'); + } } - window.closeModal = closeModal; // Make it globally accessible + window.closeModal = closeModal; window.onclick = function(event) { if (event.target.classList.contains('modal')) { - event.target.style.display = 'none'; + event.target.classList.remove('show'); } } @@ -113,14 +119,13 @@ document.addEventListener('DOMContentLoaded', () => { const loading = document.getElementById('share-loading'); const shareLinkInput = document.getElementById('share-link-input'); const shareFileName = document.getElementById('share-file-name'); - const shareLinksList = document.getElementById('share-links-list'); // New element for multiple links + const shareLinksList = document.getElementById('share-links-list'); - // Clear previous content shareLinkInput.value = ''; if (shareLinksList) shareLinksList.innerHTML = ''; linkContainer.style.display = 'none'; loading.style.display = 'block'; - modal.style.display = 'block'; + modal.classList.add('show'); if (paths.length === 1) { shareFileName.textContent = `Sharing: ${names[0]}`; @@ -180,7 +185,6 @@ document.addEventListener('DOMContentLoaded', () => { const deleteMessage = document.getElementById('delete-message'); const deleteModal = document.getElementById('delete-modal'); - // Clear previous hidden inputs deleteForm.querySelectorAll('input[name="paths[]"]').forEach(input => input.remove()); if (Array.isArray(paths) && paths.length > 1) { @@ -199,7 +203,7 @@ document.addEventListener('DOMContentLoaded', () => { deleteMessage.textContent = `Are you sure you want to delete "${name}"? This action cannot be undone.`; deleteForm.action = `/files/delete/${path}`; } - deleteModal.style.display = 'block'; + deleteModal.classList.add('show'); } // Selection and action buttons diff --git a/retoors/templates/components/footer.html b/retoors/templates/components/footer.html index 9b89e54..6f66c17 100644 --- a/retoors/templates/components/footer.html +++ b/retoors/templates/components/footer.html @@ -1,3 +1,31 @@ diff --git a/retoors/templates/components/navigation.html b/retoors/templates/components/navigation.html index 0076e5b..d29eb10 100644 --- a/retoors/templates/components/navigation.html +++ b/retoors/templates/components/navigation.html @@ -1,22 +1,26 @@ -
-