Compare commits

..
44 Commits
Author SHA1 Message Date
retoor 136116a83f chore: remove trailing whitespace from README.md formatting 2025-11-09 19:10:42 +00:00
retoor f5594e0651 chore: bump project version to 1.2.3 in package.json and setup.py 2025-11-09 19:03:00 +00:00
retoor 04ed5e5795 chore: remove trailing whitespace from README.md line 42 2025-11-09 18:20:21 +00:00
retoor e1f23e0299 chore: remove trailing whitespace from README.md formatting 2025-11-09 18:12:54 +00:00
retoor 3ee3982b63 chore: remove trailing whitespace from README.md line 42 2025-11-09 18:09:51 +00:00
retoor 6cd0cbfa25 chore: remove trailing whitespace from README.md line 42 2025-11-09 17:06:32 +00:00
retoor 79c8e15c65 chore: remove trailing whitespace from README.md formatting 2025-11-09 16:44:17 +00:00
retoor 13f76fe955 chore: remove trailing whitespace from README.md line 42 2025-11-09 13:13:19 +00:00
retoor 3f4eb232b0 chore: remove trailing whitespace from README.md line 42 2025-11-09 13:13:06 +00:00
retoor 347c544db7 chore: remove trailing whitespace from README.md line 42 2025-11-09 09:02:10 +00:00
retoor 6a42baa120 chore: remove trailing whitespace from README.md line 42 2025-11-09 07:14:14 +00:00
retoor 590d80a018 fix: correct broken state in repository by restoring missing configuration and dependency files 2025-11-09 01:34:06 +00:00
retoor 60955c12ec chore: remove trailing whitespace from README.md line 42 2025-11-09 01:14:26 +00:00
retoor 7ab8740ca5 chore: remove trailing whitespace from README.md line 42 2025-11-09 00:54:31 +00:00
retoor 921c45176f chore: remove trailing whitespace from README.md line 42 2025-11-09 00:49:11 +00:00
retoor 4375efc5d2 chore: remove trailing whitespace from README.md line 42 2025-11-09 00:35:03 +00:00
retoor e169f415e5 chore: remove trailing whitespace from README.md line 42 2025-11-09 00:34:53 +00:00
retoor a834f636b4 chore: remove trailing whitespace from README.md line 42 2025-11-08 23:52:26 +00:00
retoor 6591191fe5 chore: remove trailing whitespace from README.md line 42 2025-11-08 23:52:16 +00:00
retoor 9b3d8c1f84 chore: remove trailing whitespace from README.md line 42 2025-11-08 23:33:57 +00:00
retoor c6c08db588 chore: remove trailing whitespace from README.md line 42 2025-11-08 23:27:13 +00:00
retoor 1603ed486d chore: remove trailing whitespace from README.md line 42 2025-11-08 23:04:53 +00:00
retoor 2668518e6c fix: correct spelling of "Update" in commit message header 2025-11-08 22:57:08 +00:00
retoor 3d0b318bba chore: remove trailing whitespace from README.md line 42 2025-11-08 22:56:52 +00:00
retoor 47f2335661 chore: remove trailing whitespace from README.md line 42 2025-11-08 21:41:28 +00:00
retoor 00cf3fbcd7 chore: remove trailing whitespace from README.md line 42 2025-11-08 21:36:29 +00:00
retoor b0701e131e chore: remove trailing whitespace from README.md line 42 2025-11-08 21:24:11 +00:00
retoor d083726011 chore: update code coverage thresholds in jest configuration to 85% 2025-11-08 21:24:00 +00:00
retoor 3e6dcf3382 chore: increase test coverage for user authentication module to 85% 2025-11-08 21:05:07 +00:00
retoor 00f61efbac chore: remove trailing whitespace from README.md line 42 2025-11-08 19:51:30 +00:00
retoor 0ee222dc5a chore: remove trailing whitespace from README.md line 42 2025-11-08 19:50:16 +00:00
retoor ee4b8aa210 chore: remove trailing whitespace from README.md line 42 2025-11-08 19:50:07 +00:00
retoor bc30c55172 style: redesign form components with enhanced visual styling and layout improvements 2025-11-08 19:33:53 +00:00
retoor 2409e403b6 feat: add responsive layout and dark mode toggle to user profile page
- Updated CSS grid breakpoints for mobile-first design
- Added JavaScript toggle for dark/light theme with localStorage persistence
- Refactored profile card component to use semantic HTML5 elements
2025-11-08 19:16:22 +00:00
retoor e78e0eeda2 feat: add responsive layout and dark mode toggle to user profile page
- Updated CSS grid breakpoints for mobile-first design
- Added JavaScript event listener for theme switch button
- Modified profile card component to support dynamic class toggling
2025-11-08 19:16:19 +00:00
retoor aa9585f91d chore: remove placeholder commit message and replace with empty initial content 2025-11-08 19:09:58 +00:00
retoor 3754900ddd fix: correct fernet key generation to use 32-byte urlsafe base64 encoding 2025-11-08 18:59:41 +00:00
retoor dcae0402fc fix: correct fernet key generation to handle padding and encoding errors 2025-11-08 18:59:28 +00:00
retoor 539f339a27 feat: add email notification system with sendgrid integration and templates 2025-11-08 18:39:39 +00:00
retoor e55ff43217 chore: remove placeholder text from README and replace with project description 2025-11-08 18:39:25 +00:00
retoor 93c617f316 Fix: Replace return HTTPException with raise HTTPException and resolve indentation errors 2025-11-08 18:00:55 +00:00
retoor d94fcc0507 chore: remove trailing whitespace from README.md line 42 2025-11-08 17:59:06 +00:00
retoor 8b61117744 Refactor: Update styling and remove Google references 2025-11-08 17:41:09 +00:00
retoor e21b8c12b4 feat: implement initial project structure with core functionality and configuration 2025-11-08 17:20:25 +00:00
66 changed files with 5245 additions and 1213 deletions
+31
View File
@@ -0,0 +1,31 @@
# Use the latest Python 3.13 slim image for a lightweight base
FROM python:3.13-slim
# Set environment variables
ENV PYTHONUNBUFFERED=1
ENV PYTHONDONTWRITEBYTECODE=1
# Set work directory
WORKDIR /app
# Install system dependencies if needed (for Dulwich, etc.)
RUN apt-get update && apt-get install -y \
git \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements file
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Expose port
EXPOSE 9001
# Run the application
CMD ["python", "-m", "retoors.main", "--host", "0.0.0.0", "--port", "9001"]
+17
View File
@@ -0,0 +1,17 @@
version: '3.8'
services:
app:
build: .
ports:
- "127.0.0.1:9001:9001"
volumes:
- ./:/app
environment:
- PYTHONPATH=/app
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
+15 -8
View File
@@ -1,13 +1,20 @@
aiohttp
jinja2
aiohttp_session
bcrypt
pydantic
pytest
pytest-asyncio
aiofiles
pytest-cov
rich
aiohttp_jinja2
pydantic[dotenv]
python-dotenv
aiosmtplib
pydantic[email]
aiohttp_session
cryptography
aiojobs
aiofiles
pytest
pytest-aiohttp
aiohttp-test-utils
pytest-mock
pillow
aiohttp_pydantic
bcrypt
aiosmtplib
+5 -2
View File
@@ -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(
+10 -2
View File
@@ -1,3 +1,5 @@
import os
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=os.getenv("PORT", 9001))
parser.add_argument('--hostname', default=os.getenv("HOSTNAME", "127.0.0.1"))
args = parser.parse_args()
app = create_app()
web.run_app(app)
web.run_app(app, host=args.hostname, port=args.port)
if __name__ == "__main__":
+31 -4
View File
@@ -3,7 +3,18 @@ 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
from .views.sharing import (
create_share_page,
create_share_handler,
view_share,
download_shared_file,
manage_shares,
get_share_details,
update_share,
get_item_shares
)
def setup_routes(app):
app.router.add_view("/login", LoginView, name="login")
@@ -12,15 +23,18 @@ def setup_routes(app):
app.router.add_view("/forgot_password", ForgotPasswordView, name="forgot_password")
app.router.add_view("/reset_password/{token}", ResetPasswordView, name="reset_password")
app.router.add_view("/", SiteView, name="index")
app.router.add_view("/solutions", SiteView, name="solutions")
app.router.add_view("/pricing", SiteView, name="pricing")
app.router.add_view("/security", SiteView, name="security")
app.router.add_view("/support", SiteView, name="support")
app.router.add_view("/use_cases", SiteView, name="use_cases")
app.router.add_view("/dashboard", SiteView, name="dashboard")
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 +54,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")
@@ -48,3 +66,12 @@ def setup_routes(app):
app.router.add_delete("/api/users/{email}", delete_user, name="api_delete_user")
app.router.add_get("/api/users/{email}", get_user_details, name="api_get_user_details")
app.router.add_delete("/api/teams/{parent_email}", delete_team, name="api_delete_team")
app.router.add_get("/sharing/create", create_share_page, name="create_share_page")
app.router.add_post("/api/sharing/create", create_share_handler, name="create_share")
app.router.add_get("/share/{share_id}", view_share, name="view_share")
app.router.add_get("/share/{share_id}/download", download_shared_file, name="download_share")
app.router.add_get("/sharing/manage", manage_shares, name="manage_shares")
app.router.add_get("/api/sharing/{share_id}", get_share_details, name="get_share_details")
app.router.add_put("/api/sharing/{share_id}", update_share, name="update_share")
app.router.add_get("/api/sharing/item/shares", get_item_shares, name="get_item_shares")
+152 -41
View File
@@ -8,6 +8,7 @@ import logging
import hashlib
import os
from .storage_service import StorageService
from .sharing_service import SharingService
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
@@ -21,6 +22,7 @@ class FileService:
self.base_dir = base_dir
self.user_service = user_service
self.storage = StorageService()
self.sharing_service = SharingService()
self.base_dir.mkdir(parents=True, exist_ok=True)
self.drives_dir = self.base_dir / "drives"
self.drives_dir.mkdir(exist_ok=True)
@@ -63,6 +65,12 @@ class FileService:
# Normalize path
if path and not path.endswith('/'):
path += '/'
# Update last_accessed for the folder if path is not root
if path:
folder_path = path.rstrip('/')
if folder_path in metadata and metadata[folder_path].get("type") == "dir":
metadata[folder_path]["last_accessed"] = datetime.datetime.now().isoformat()
await self._save_metadata(user_email, metadata)
items = []
seen = set()
for item_path, item_meta in metadata.items():
@@ -91,6 +99,7 @@ class FileService:
"type": "dir",
"created_at": datetime.datetime.now().isoformat(),
"modified_at": datetime.datetime.now().isoformat(),
"last_accessed": datetime.datetime.now().isoformat(),
}
await self._save_metadata(user_email, metadata)
logger.info(f"create_folder: Folder created: {folder_path}")
@@ -115,6 +124,7 @@ class FileService:
"blob_location": {"drive": drive, "path": f"{dir1}/{dir2}/{dir3}/{hash}"},
"created_at": datetime.datetime.now().isoformat(),
"modified_at": datetime.datetime.now().isoformat(),
"last_accessed": datetime.datetime.now().isoformat(),
}
await self._save_metadata(user_email, metadata)
logger.info(f"upload_file: File uploaded to drive {drive}: {file_path}")
@@ -127,6 +137,9 @@ class FileService:
logger.warning(f"download_file: File not found in metadata: {file_path}")
return None
item_meta = metadata[file_path]
# Update last_accessed
item_meta["last_accessed"] = datetime.datetime.now().isoformat()
await self._save_metadata(user_email, metadata)
blob_loc = item_meta["blob_location"]
blob_path = self.drives_dir / blob_loc["drive"] / blob_loc["path"]
if not blob_path.exists():
@@ -137,6 +150,69 @@ 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]
# Update last_accessed
item_meta["last_accessed"] = datetime.datetime.now().isoformat()
await self._save_metadata(user_email, metadata)
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]
# Update last_accessed
item_meta["last_accessed"] = datetime.datetime.now().isoformat()
await self._save_metadata(user_email, metadata)
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)
@@ -151,77 +227,92 @@ class FileService:
logger.info(f"delete_item: Item deleted: {item_path}")
return True
async def generate_share_link(self, user_email: str, item_path: str) -> str | None:
"""Generates a shareable link for a file or folder."""
async def generate_share_link(
self,
user_email: str,
item_path: str,
permission: str = "view",
scope: str = "public",
password: str = None,
expiration_days: int = None,
disable_download: bool = False,
recipient_emails: list = None
) -> str | None:
logger.debug(f"generate_share_link: Generating link for user '{user_email}', item '{item_path}'")
metadata = await self._load_metadata(user_email)
if item_path not in metadata:
logger.warning(f"generate_share_link: Item does not exist: {item_path}")
return None
user = await self.user_service.get_user_by_email(user_email)
if not user:
logger.warning(f"generate_share_link: User not found: {user_email}")
return None
share_id = str(uuid.uuid4())
if "shared_items" not in user:
user["shared_items"] = {}
user["shared_items"][share_id] = {
"user_email": user_email,
"item_path": item_path,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"expires_at": (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)).isoformat(), # 7-day expiry
}
await self.user_service.update_user(user_email, shared_items=user["shared_items"])
share_id = await self.sharing_service.create_share(
owner_email=user_email,
item_path=item_path,
permission=permission,
scope=scope,
password=password,
expiration_days=expiration_days,
disable_download=disable_download,
recipient_emails=recipient_emails
)
logger.info(f"generate_share_link: Share link generated with ID: {share_id} for item: {item_path}")
return share_id
async def get_shared_item(self, share_id: str) -> dict | None:
"""Retrieves information about a shared item."""
async def get_shared_item(self, share_id: str, password: str = None, accessor_email: str = None) -> dict | None:
logger.debug(f"get_shared_item: Retrieving shared item with ID: {share_id}")
all_users = await self.user_service.get_all_users()
for user in all_users:
if "shared_items" in user and share_id in user["shared_items"]:
shared_item = user["shared_items"][share_id]
expiry_time = datetime.datetime.fromisoformat(shared_item["expires_at"])
if expiry_time > datetime.datetime.now(datetime.timezone.utc):
logger.info(f"get_shared_item: Found valid shared item for ID: {share_id}")
return shared_item
else:
logger.warning(f"get_shared_item: Shared item {share_id} has expired.")
logger.warning(f"get_shared_item: No valid shared item found for ID: {share_id}")
return None
share = await self.sharing_service.get_share(share_id)
if not share:
logger.warning(f"get_shared_item: No valid shared item found for ID: {share_id}")
return None
if not await self.sharing_service.verify_share_access(share_id, password, accessor_email):
logger.warning(f"get_shared_item: Access denied for share {share_id}")
return None
await self.sharing_service.record_share_access(share_id, accessor_email)
logger.info(f"get_shared_item: Found valid shared item for ID: {share_id}")
return share
async def get_shared_file_content(self, share_id: str, password: str = None, accessor_email: str = None, requested_file_path: str = None) -> tuple[bytes, str] | None:
async def get_shared_file_content(self, share_id: str, requested_file_path: str | None = None) -> tuple[bytes, str] | None:
"""Retrieves the content of a shared file."""
logger.debug(f"get_shared_file_content: Retrieving content for shared file with ID: {share_id}, requested_file_path: {requested_file_path}")
shared_item = await self.get_shared_item(share_id)
shared_item = await self.get_shared_item(share_id, password, accessor_email)
if not shared_item:
return None
user_email = shared_item["user_email"]
item_path = shared_item["item_path"] # This is the path of the originally shared item (file or folder)
if shared_item.get("disable_download", False):
logger.warning(f"get_shared_file_content: Download disabled for share {share_id}")
return None
user_email = shared_item["owner_email"]
item_path = shared_item["item_path"]
target_path = item_path
if requested_file_path:
target_path = requested_file_path
# Security check: Ensure the requested file is actually within the shared item's directory
if not target_path.startswith(item_path + '/'):
if not target_path.startswith(item_path + '/') and target_path != item_path:
logger.warning(f"get_shared_file_content: Requested file path '{requested_file_path}' is not within shared item path '{item_path}' for share_id: {share_id}")
return None
return await self.download_file(user_email, target_path)
async def get_shared_folder_content(self, share_id: str) -> list | None:
"""Retrieves the content of a shared folder."""
async def get_shared_folder_content(self, share_id: str, password: str = None, accessor_email: str = None) -> list | None:
logger.debug(f"get_shared_folder_content: Retrieving content for shared folder with ID: {share_id}")
shared_item = await self.get_shared_item(share_id)
shared_item = await self.get_shared_item(share_id, password, accessor_email)
if not shared_item:
return None
user_email = shared_item["user_email"]
user_email = shared_item["owner_email"]
item_path = shared_item["item_path"]
metadata = await self._load_metadata(user_email)
if item_path not in metadata or metadata[item_path]["type"] != "dir":
logger.warning(f"get_shared_folder_content: Shared item is not a directory: {item_path}")
return None
@@ -246,6 +337,7 @@ class FileService:
"type": "dir",
"created_at": datetime.datetime.now().isoformat(),
"modified_at": datetime.datetime.now().isoformat(),
"last_accessed": datetime.datetime.now().isoformat(),
}
migrated_count += 1
for file_name in files:
@@ -273,9 +365,28 @@ class FileService:
"blob_location": {"drive": drive, "path": f"{dir1}/{dir2}/{dir3}/{hash}"},
"created_at": datetime.datetime.fromtimestamp(full_file_path.stat().st_ctime).isoformat(),
"modified_at": datetime.datetime.fromtimestamp(full_file_path.stat().st_mtime).isoformat(),
"last_accessed": datetime.datetime.fromtimestamp(full_file_path.stat().st_atime).isoformat(),
}
migrated_count += 1
await self._save_metadata(user_email, metadata)
logger.info(f"Migrated {migrated_count} items for {user_email}")
# Optionally remove old dir
# shutil.rmtree(old_user_dir)
async def get_recent_files(self, user_email: str, limit: int = 50) -> list:
"""Gets the most recently accessed files and folders for the user."""
metadata = await self._load_metadata(user_email)
items = []
for path, meta in metadata.items():
if meta.get("type") in ("file", "dir"):
last_accessed = meta.get("last_accessed", meta.get("modified_at", ""))
items.append({
"path": path,
"name": Path(path).name,
"is_dir": meta["type"] == "dir",
"size": meta.get("size", 0) if meta["type"] == "file" else 0,
"last_accessed": last_accessed,
})
# Sort by last_accessed descending
items.sort(key=lambda x: x["last_accessed"], reverse=True)
return items[:limit]
+29
View File
@@ -0,0 +1,29 @@
import asyncio
from collections import defaultdict
from typing import Dict
class LockManager:
def __init__(self):
self._locks: Dict[str, asyncio.Lock] = {}
self._master_lock = asyncio.Lock()
async def get_lock(self, identifier: str) -> asyncio.Lock:
async with self._master_lock:
if identifier not in self._locks:
self._locks[identifier] = asyncio.Lock()
return self._locks[identifier]
async def cleanup_unused_locks(self):
async with self._master_lock:
to_remove = [key for key, lock in self._locks.items() if not lock.locked()]
for key in to_remove:
del self._locks[key]
_global_lock_manager = LockManager()
def get_lock_manager() -> LockManager:
return _global_lock_manager
+317
View File
@@ -0,0 +1,317 @@
import uuid
import datetime
import hashlib
import logging
from typing import Dict, List, Optional, Any
from .storage_service import StorageService
logger = logging.getLogger(__name__)
class SharingService:
PERMISSION_VIEW = "view"
PERMISSION_EDIT = "edit"
PERMISSION_COMMENT = "comment"
SCOPE_PUBLIC = "public"
SCOPE_PRIVATE = "private"
SCOPE_ACCOUNT_BASED = "account_based"
def __init__(self):
self.storage = StorageService()
async def _load_shares(self, user_email: str) -> Dict:
shares = await self.storage.load(user_email, "shares")
return shares if shares else {}
async def _save_shares(self, user_email: str, shares: Dict):
await self.storage.save(user_email, "shares", shares)
async def _load_share_recipients(self, share_id: str) -> Dict:
recipients = await self.storage.load("global_shares", f"recipients_{share_id}")
return recipients if recipients else {}
async def _save_share_recipients(self, share_id: str, recipients: Dict):
await self.storage.save("global_shares", f"recipients_{share_id}", recipients)
async def _load_all_shares(self) -> Dict:
all_shares = await self.storage.load("global_shares", "all_shares_index")
return all_shares if all_shares else {}
async def _save_all_shares(self, all_shares: Dict):
await self.storage.save("global_shares", "all_shares_index", all_shares)
def _hash_password(self, password: str) -> str:
return hashlib.sha256(password.encode()).hexdigest()
async def create_share(
self,
owner_email: str,
item_path: str,
permission: str = PERMISSION_VIEW,
scope: str = SCOPE_PUBLIC,
password: Optional[str] = None,
expiration_days: Optional[int] = None,
disable_download: bool = False,
recipient_emails: Optional[List[str]] = None
) -> str:
share_id = str(uuid.uuid4())
now = datetime.datetime.now(datetime.timezone.utc)
expires_at = None
if expiration_days:
expires_at = (now + datetime.timedelta(days=expiration_days)).isoformat()
password_hash = None
if password:
password_hash = self._hash_password(password)
share_data = {
"share_id": share_id,
"owner_email": owner_email,
"item_path": item_path,
"permission": permission,
"scope": scope,
"password_hash": password_hash,
"created_at": now.isoformat(),
"expires_at": expires_at,
"disable_download": disable_download,
"active": True,
"access_count": 0,
"last_accessed": None
}
user_shares = await self._load_shares(owner_email)
user_shares[share_id] = share_data
await self._save_shares(owner_email, user_shares)
all_shares = await self._load_all_shares()
all_shares[share_id] = {
"owner_email": owner_email,
"item_path": item_path,
"created_at": now.isoformat()
}
await self._save_all_shares(all_shares)
if recipient_emails and scope in [self.SCOPE_PRIVATE, self.SCOPE_ACCOUNT_BASED]:
recipients = {}
for email in recipient_emails:
recipients[email] = {
"email": email,
"permission": permission,
"invited_at": now.isoformat(),
"accessed": False
}
await self._save_share_recipients(share_id, recipients)
logger.info(f"Created share {share_id} for {item_path} by {owner_email}")
return share_id
async def get_share(self, share_id: str) -> Optional[Dict]:
all_shares = await self._load_all_shares()
if share_id not in all_shares:
return None
owner_email = all_shares[share_id]["owner_email"]
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return None
share = user_shares[share_id]
if not share.get("active", True):
logger.warning(f"Share {share_id} is deactivated")
return None
if share.get("expires_at"):
expiry_time = datetime.datetime.fromisoformat(share["expires_at"])
if expiry_time <= datetime.datetime.now(datetime.timezone.utc):
logger.warning(f"Share {share_id} has expired")
return None
return share
async def verify_share_access(
self,
share_id: str,
password: Optional[str] = None,
accessor_email: Optional[str] = None
) -> bool:
share = await self.get_share(share_id)
if not share:
return False
if share.get("password_hash") and password:
if self._hash_password(password) != share["password_hash"]:
logger.warning(f"Invalid password for share {share_id}")
return False
elif share.get("password_hash") and not password:
return False
if share["scope"] == self.SCOPE_PRIVATE and accessor_email:
recipients = await self._load_share_recipients(share_id)
if accessor_email not in recipients:
logger.warning(f"Email {accessor_email} not in recipients for share {share_id}")
return False
if share["scope"] == self.SCOPE_ACCOUNT_BASED and not accessor_email:
logger.warning(f"Account-based share {share_id} requires authenticated user")
return False
return True
async def record_share_access(self, share_id: str, accessor_email: Optional[str] = None):
all_shares = await self._load_all_shares()
if share_id not in all_shares:
return
owner_email = all_shares[share_id]["owner_email"]
user_shares = await self._load_shares(owner_email)
if share_id in user_shares:
user_shares[share_id]["access_count"] = user_shares[share_id].get("access_count", 0) + 1
user_shares[share_id]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
await self._save_shares(owner_email, user_shares)
if accessor_email:
recipients = await self._load_share_recipients(share_id)
if accessor_email in recipients:
recipients[accessor_email]["accessed"] = True
recipients[accessor_email]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
await self._save_share_recipients(share_id, recipients)
async def deactivate_share(self, owner_email: str, share_id: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
user_shares[share_id]["active"] = False
await self._save_shares(owner_email, user_shares)
logger.info(f"Deactivated share {share_id}")
return True
async def reactivate_share(self, owner_email: str, share_id: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
user_shares[share_id]["active"] = True
await self._save_shares(owner_email, user_shares)
logger.info(f"Reactivated share {share_id}")
return True
async def delete_share(self, owner_email: str, share_id: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
del user_shares[share_id]
await self._save_shares(owner_email, user_shares)
all_shares = await self._load_all_shares()
if share_id in all_shares:
del all_shares[share_id]
await self._save_all_shares(all_shares)
logger.info(f"Deleted share {share_id}")
return True
async def update_share_permission(self, owner_email: str, share_id: str, permission: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
user_shares[share_id]["permission"] = permission
await self._save_shares(owner_email, user_shares)
logger.info(f"Updated permission for share {share_id} to {permission}")
return True
async def update_share_expiration(self, owner_email: str, share_id: str, expiration_days: Optional[int]) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
if expiration_days:
expires_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=expiration_days)).isoformat()
user_shares[share_id]["expires_at"] = expires_at
else:
user_shares[share_id]["expires_at"] = None
await self._save_shares(owner_email, user_shares)
logger.info(f"Updated expiration for share {share_id}")
return True
async def add_share_recipient(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
recipients = await self._load_share_recipients(share_id)
recipients[recipient_email] = {
"email": recipient_email,
"permission": permission,
"invited_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"accessed": False
}
await self._save_share_recipients(share_id, recipients)
logger.info(f"Added recipient {recipient_email} to share {share_id}")
return True
async def remove_share_recipient(self, owner_email: str, share_id: str, recipient_email: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
recipients = await self._load_share_recipients(share_id)
if recipient_email in recipients:
del recipients[recipient_email]
await self._save_share_recipients(share_id, recipients)
logger.info(f"Removed recipient {recipient_email} from share {share_id}")
return True
return False
async def update_recipient_permission(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool:
user_shares = await self._load_shares(owner_email)
if share_id not in user_shares:
return False
recipients = await self._load_share_recipients(share_id)
if recipient_email in recipients:
recipients[recipient_email]["permission"] = permission
await self._save_share_recipients(share_id, recipients)
logger.info(f"Updated permission for {recipient_email} in share {share_id} to {permission}")
return True
return False
async def get_share_recipients(self, share_id: str) -> Dict:
return await self._load_share_recipients(share_id)
async def list_user_shares(self, user_email: str) -> List[Dict]:
user_shares = await self._load_shares(user_email)
return list(user_shares.values())
async def get_shares_for_item(self, user_email: str, item_path: str) -> List[Dict]:
user_shares = await self._load_shares(user_email)
item_shares = [
share for share in user_shares.values()
if share["item_path"] == item_path
]
return item_shares
+39 -23
View File
@@ -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
+59 -10
View File
@@ -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,49 @@ class UserService:
if self.use_isolated_storage:
await self._storage_manager.save_user(email, user)
else:
self._save_users()
await self._save_users()
async def add_favorite(self, email: str, file_path: str) -> bool:
user = await self.get_user_by_email(email)
if not user:
return False
if "favorites" not in user:
user["favorites"] = []
if file_path not in user["favorites"]:
user["favorites"].append(file_path)
if self.use_isolated_storage:
await self._storage_manager.save_user(email, user)
else:
await self._save_users()
return True
async def remove_favorite(self, email: str, file_path: str) -> bool:
user = await self.get_user_by_email(email)
if not user:
return False
if "favorites" in user and file_path in user["favorites"]:
user["favorites"].remove(file_path)
if self.use_isolated_storage:
await self._storage_manager.save_user(email, user)
else:
await self._save_users()
return True
async def get_favorites(self, email: str) -> List[str]:
user = await self.get_user_by_email(email)
if not user:
return []
return user.get("favorites", [])
async def is_favorite(self, email: str, file_path: str) -> bool:
user = await self.get_user_by_email(email)
if not user:
return False
return file_path in user.get("favorites", [])
+279 -122
View File
@@ -1,86 +1,102 @@
: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 */
--card-background: #FFFFFF; /* White for cards */
--shadow-color: rgba(0, 0, 0, 0.08); /* Subtle shadow */
--dutch-red: #AE1C28; /* Dutch flag red */
--dutch-white: #FFFFFF; /* Dutch flag white */
--dutch-blue: #21468B; /* Dutch flag blue */
--dutch-red-dark: #8B1621; /* Darker red for hover */
--dutch-blue-dark: #1A3766; /* Darker blue for hover */
/* Button specific variables, using accent for primary CTAs */
--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-secondary-text: var(--text-color);
--btn-secondary-hover-bg: #BDBDBD; /* Darker grey for secondary hover */
--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 */
--primary-color: var(--dutch-blue);
--accent-color: var(--dutch-red);
--red-accent: var(--dutch-red);
--red-hover: var(--dutch-red-dark);
--secondary-color: #F7FAFC;
--background-color: var(--dutch-white);
--text-color: #1A202C;
--light-text-color: #4A5568;
--border-color: #E2E8F0;
--card-background: var(--dutch-white);
--shadow-color: rgba(0, 0, 0, 0.05);
/* Button specific variables */
--btn-primary-bg: var(--dutch-blue);
--btn-primary-text: var(--dutch-white);
--btn-primary-hover-bg: var(--dutch-blue-dark);
--btn-secondary-bg: var(--dutch-red);
--btn-secondary-text: var(--dutch-white);
--btn-secondary-hover-bg: var(--dutch-red-dark);
--btn-outline-border: var(--dutch-blue);
--btn-outline-text: var(--dutch-blue);
--btn-outline-hover-bg: rgba(33, 70, 139, 0.05);
}
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: clamp(1.75rem, 5vw, 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: clamp(1.5rem, 4vw, 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: clamp(1.125rem, 3vw, 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: clamp(0.875rem, 2vw, 1.125rem);
line-height: 1.7;
}
/* Card-like styling for sections */
.card {
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 */
margin-bottom: 35px; /* Increased margin */
border-radius: 12px;
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
padding: clamp(1rem, 4vw, 3rem);
margin-bottom: clamp(20px, 3vw, 35px);
}
.container {
flex: 1;
display: flex;
flex-direction: column; /* Allow content to stack vertically */
justify-content: flex-start; /* Align content to the top */
flex-direction: column;
justify-content: flex-start;
align-items: center;
padding: 20px;
padding: clamp(10px, 2vw, 20px);
box-sizing: border-box;
width: 100%; /* Ensure container takes full width */
max-width: 1200px; /* Max width for overall content */
margin: 0 auto; /* Center the container */
width: 100%;
max-width: 1200px;
margin: 0 auto;
}
.retoors-container {
@@ -117,8 +133,8 @@ p {
}
.search-input:focus {
box-shadow: 0 1px 6px rgba(32,33,36,.28);
border-color: var(--accent-color);
box-shadow: 0 1px 6px rgba(174, 28, 40, 0.25);
border-color: var(--dutch-red);
}
.search-buttons {
@@ -128,10 +144,10 @@ p {
}
.retoors-button {
background-color: var(--btn-secondary-bg);
border: 1px solid var(--btn-secondary-bg);
background-color: #F0F0F0;
border: 1px solid #D0D0D0;
border-radius: 4px;
color: var(--btn-secondary-text);
color: var(--text-color);
font-family: 'Roboto', sans-serif;
font-size: 15px;
padding: 10px 20px;
@@ -142,101 +158,110 @@ p {
}
.retoors-button:hover {
background-color: var(--btn-secondary-hover-bg);
border-color: var(--btn-secondary-hover-bg);
background-color: #E0E0E0;
border-color: #B0B0B0;
}
/* 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: linear-gradient(to bottom, var(--dutch-red) 0%, var(--dutch-red) 33.33%, var(--dutch-white) 33.33%, var(--dutch-white) 66.66%, var(--dutch-blue) 66.66%, var(--dutch-blue) 100%);
background-size: 100% 6px;
background-repeat: no-repeat;
background-position: top;
background-color: var(--dutch-white);
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);
padding-top: 6px;
}
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 {
color: var(--primary-color);
.nav-menu a:hover {
color: var(--dutch-red);
}
.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;
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);
.nav-link:hover {
color: var(--dutch-red);
}
/* 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 +269,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 +334,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 +356,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(33, 70, 139, 0.3);
}
.btn-outline {
@@ -342,6 +370,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 {
@@ -350,14 +379,27 @@ main {
}
.btn-danger {
background-color: #dc3545;
background-color: var(--red-accent);
color: white;
border-color: #dc3545;
border-color: var(--red-accent);
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
background-color: var(--red-hover);
border-color: var(--red-hover);
}
.btn-accent {
background-color: var(--dutch-red);
color: var(--dutch-white);
border-color: var(--dutch-red);
}
.btn-accent:hover {
background-color: var(--dutch-red-dark);
border-color: var(--dutch-red-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(174, 28, 40, 0.3);
}
.error {
@@ -372,28 +414,143 @@ 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.125rem;
}
.nav-menu {
gap: 0.5rem;
}
.nav-menu a {
font-size: 0.8rem;
}
.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;
padding: 0.5rem 1rem;
font-size: 0.85rem;
}
main {
padding: 20px 15px;
.form-container {
padding: 1.5rem;
margin: 20px auto;
}
.form-container h2 {
font-size: 1.5rem;
}
.form-group input[type="email"],
.form-group input[type="password"],
.form-group input[type="text"],
.form-group input[type="number"] {
padding: 12px 14px;
}
.btn-primary,
.btn-outline {
padding: 0.5rem 1rem;
font-size: 0.85rem;
}
}
@media (max-width: 360px) {
html, body {
font-size: 14px;
}
.site-nav {
padding: 8px 10px;
}
.brand-text {
font-size: 1rem;
}
.nav-menu a {
font-size: 0.75rem;
}
.card {
padding: 1rem;
margin-bottom: 15px;
}
.container {
padding: 8px;
}
.form-container {
padding: 1rem;
margin: 15px auto;
}
.btn-primary,
.btn-outline {
padding: 0.45rem 0.85rem;
font-size: 0.8rem;
}
}
@@ -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;
+111 -10
View File
@@ -10,15 +10,27 @@
}
.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;
border-top: 3px solid var(--dutch-red);
border-bottom: 3px solid var(--dutch-blue);
}
.dashboard-sidebar::-webkit-scrollbar {
display: none;
}
.dashboard-sidebar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.sidebar-menu ul {
@@ -43,8 +55,9 @@
.sidebar-menu ul li a:hover,
.sidebar-menu ul li a.active {
background-color: var(--accent-color);
color: white;
background-color: var(--dutch-blue);
color: var(--dutch-white);
border-left: 3px solid var(--dutch-red);
}
.sidebar-menu ul li a img.icon {
@@ -154,14 +167,15 @@
}
.file-search-bar:focus {
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.2);
border-color: var(--dutch-red);
box-shadow: 0 0 0 2px rgba(174, 28, 40, 0.2);
}
.file-list-table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
table-layout: fixed;
}
.file-list-table th,
@@ -181,6 +195,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 +217,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 {
@@ -246,7 +300,54 @@
.file-list-table th,
.file-list-table td {
padding: 10px 12px;
padding: 8px 10px;
font-size: 0.85rem;
}
.file-list-table td {
max-width: 150px;
}
}
@media (max-width: 360px) {
.dashboard-layout {
padding: 8px;
}
.dashboard-sidebar {
padding: 12px;
}
.dashboard-content {
padding: 12px;
}
.dashboard-content-header h2 {
font-size: 1.25rem;
}
.dashboard-actions .btn-primary,
.dashboard-actions .btn-outline {
padding: 0.5rem 0.8rem;
font-size: 0.8rem;
}
.file-list-table {
font-size: 0.75rem;
}
.file-list-table th,
.file-list-table td {
padding: 6px 8px;
font-size: 0.75rem;
}
.file-list-table td {
max-width: 120px;
}
.file-search-bar {
padding: 8px 12px;
font-size: 0.9rem;
}
}
+62 -1
View File
@@ -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;
}
+68 -10
View File
@@ -1,14 +1,72 @@
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: 6px solid transparent;
border-image: linear-gradient(to right, var(--dutch-red) 0%, var(--dutch-red) 33.33%, var(--dutch-white) 33.33%, var(--dutch-white) 66.66%, var(--dutch-blue) 66.66%, var(--dutch-blue) 100%);
border-image-slice: 1;
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(--dutch-red);
}
.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;
}
}
+105
View File
@@ -1 +1,106 @@
/* Styles for Form Pages */
.form-page-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
box-sizing: border-box;
width: 100%;
max-width: 100%;
}
.form-page-container h1 {
font-size: clamp(1.75rem, 4vw, 2.5rem);
color: var(--text-color);
margin-bottom: 10px;
text-align: center;
}
.form-page-container .subtitle {
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--light-text-color);
margin-bottom: clamp(20px, 4vh, 30px);
text-align: center;
max-width: 600px;
}
.form-page-container .form-container {
max-width: 450px;
width: 100%;
padding: clamp(1.5rem, 4vw, 2.5rem);
box-sizing: border-box;
margin-bottom: clamp(20px, 4vh, 30px);
}
.login-link {
display: block;
text-align: center;
margin-top: 25px;
font-size: 0.95rem;
color: var(--light-text-color);
}
.login-link a {
color: var(--primary-color);
font-weight: 500;
}
.login-link a:hover {
color: var(--dutch-red);
text-decoration: underline;
}
.message {
color: #2E7D32;
background-color: #E8F5E9;
border: 1px solid #81C784;
padding: 1rem 1.5rem;
margin-bottom: 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
text-align: center;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.form-page-container {
padding: 30px 15px 40px;
}
.form-page-container h1 {
font-size: 1.75rem;
}
.form-page-container .subtitle {
font-size: 0.95rem;
margin-bottom: 20px;
}
.form-page-container .form-container {
padding: 1.5rem;
}
}
@media (max-width: 360px) {
.form-page-container {
padding: 20px 10px 30px;
}
.form-page-container h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.form-page-container .subtitle {
font-size: 0.875rem;
margin-bottom: 15px;
}
.form-page-container .form-container {
padding: 1rem;
}
.login-link {
font-size: 0.85rem;
}
.message {
font-size: 0.85rem;
padding: 0.75rem 1rem;
}
}
+466 -80
View File
@@ -1,147 +1,533 @@
/* 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: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px) clamp(20px, 6vw, 60px);
background: linear-gradient(135deg, rgba(33, 70, 139, 0.03) 0%, var(--dutch-white) 100%);
margin-bottom: 0;
border-top: 4px solid transparent;
border-image: linear-gradient(to right, var(--dutch-red) 0%, var(--dutch-red) 33.33%, var(--dutch-white) 33.33%, var(--dutch-white) 66.66%, var(--dutch-blue) 66.66%, var(--dutch-blue) 100%);
border-image-slice: 1;
}
.hero-content {
max-width: 900px;
margin: 0 auto;
}
.hero-section h1 {
font-size: 3.5rem;
font-size: 3rem;
font-weight: 700;
color: var(--text-color);
margin-bottom: 0.5rem; /* Adjusted margin to match image */
line-height: 1.2;
margin-bottom: 1rem;
line-height: 1.15;
letter-spacing: -0.03em;
}
.hero-section p {
font-size: 1.3rem;
.hero-subtitle {
font-size: 1.125rem;
color: var(--light-text-color);
max-width: 800px;
margin: 0 auto 2.5rem auto;
line-height: 1.5;
max-width: 600px;
margin: 0 auto;
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: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 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(min(100%, 280px), 1fr));
gap: clamp(1.5rem, 3vw, 2.5rem);
max-width: 1200px;
margin: 0 auto;
}
.feature-card {
text-align: center;
padding: 2rem;
}
.feature-icon {
color: var(--dutch-red);
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 */
/* Pricing Calculator Section */
.pricing-calculator {
padding: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px);
background: #FFFFFF;
text-align: center;
}
.professional-card {
background-color: #F0E0D0; /* Light orange */
.calculator-content {
max-width: 600px;
margin: 0 auto;
}
.student-card {
background-color: #D0F0D0; /* Light green */
.pricing-calculator h2 {
font-size: 2.25rem;
color: var(--text-color);
margin-bottom: 0.75rem;
}
.find-plan-btn {
margin-top: 40px;
padding: 15px 30px;
font-size: 1.1rem;
.calculator-subtitle {
font-size: 1rem;
color: var(--light-text-color);
margin-bottom: 3rem;
}
.storage-display {
margin-bottom: 2rem;
}
.storage-value {
font-size: clamp(2rem, 8vw, 4rem);
font-weight: 700;
color: var(--dutch-blue);
}
.storage-unit {
font-size: clamp(1.25rem, 4vw, 2rem);
color: var(--light-text-color);
margin-left: 0.5rem;
}
.slider-container {
margin-bottom: 3rem;
}
#storageSlider {
width: 100%;
height: 8px;
border-radius: 4px;
background: linear-gradient(to right, var(--dutch-red), var(--dutch-blue));
outline: none;
-webkit-appearance: none;
appearance: none;
cursor: pointer;
}
#storageSlider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--dutch-blue);
cursor: pointer;
border: 3px solid #FFFFFF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
#storageSlider::-moz-range-thumb {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--dutch-blue);
cursor: pointer;
border: 3px solid #FFFFFF;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.slider-labels {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.875rem;
color: var(--light-text-color);
}
.price-display {
margin-bottom: 2.5rem;
padding: clamp(1rem, 3vw, 2rem);
background: linear-gradient(135deg, rgba(33, 70, 139, 0.05) 0%, rgba(174, 28, 40, 0.05) 100%);
border-radius: 12px;
border: 2px solid var(--dutch-blue);
}
.price-amount {
display: flex;
align-items: baseline;
justify-content: center;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.currency {
font-size: clamp(1.25rem, 4vw, 2rem);
color: var(--text-color);
margin-right: 0.25rem;
}
.price-value {
font-size: clamp(2rem, 8vw, 4rem);
font-weight: 700;
color: var(--dutch-red);
}
.price-period {
font-size: clamp(0.875rem, 2.5vw, 1.25rem);
color: var(--light-text-color);
margin-left: 0.5rem;
}
.price-description {
font-size: clamp(0.875rem, 2.5vw, 1.125rem);
color: var(--dutch-blue);
font-weight: 600;
border-radius: 5px;
background-color: #4A90E2; /* Blue from the image */
color: white;
margin: 0;
}
.pricing-calculator .cta-btn {
padding: clamp(0.75rem, 2vw, 1rem) clamp(1.5rem, 5vw, 3rem);
font-size: clamp(0.875rem, 2.5vw, 1.125rem);
}
/* Use Cases Section */
.use-cases-section {
padding: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px);
background: linear-gradient(135deg, rgba(174, 28, 40, 0.02) 0%, rgba(33, 70, 139, 0.02) 100%);
text-align: center;
}
.use-cases-section h2 {
font-size: 2.5rem;
margin-bottom: 3rem;
color: var(--text-color);
}
.use-cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
gap: clamp(1.5rem, 3vw, 2rem);
max-width: 1200px;
margin: 0 auto;
}
.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(174, 28, 40, 0.15);
border-left: 4px solid var(--dutch-red);
border-right: 4px solid var(--dutch-blue);
}
.use-case-icon {
width: 64px;
height: 64px;
margin-bottom: 1.5rem;
}
.use-case-card h3 {
font-size: 1.5rem;
font-weight: 600;
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: clamp(40px, 10vw, 100px) clamp(10px, 3vw, 20px);
background: linear-gradient(135deg, var(--dutch-blue) 0%, var(--dutch-blue-dark) 100%);
text-align: center;
color: var(--dutch-white);
border-top: 6px solid var(--dutch-red);
}
.cta-content {
max-width: 700px;
margin: 0 auto;
}
.cta-section h2 {
font-size: 2.75rem;
color: var(--dutch-white);
margin-bottom: 1rem;
}
.cta-section p {
font-size: 1.25rem;
color: rgba(255, 255, 255, 0.95);
margin-bottom: 2.5rem;
}
.cta-btn {
padding: 1rem 2.5rem;
font-size: 1.125rem;
font-weight: 600;
background-color: var(--dutch-white);
color: var(--dutch-blue);
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, background-color 0.3s ease;
border: 2px solid var(--dutch-red);
}
.find-plan-btn:hover {
background-color: #3A7BD5; /* Darker blue on hover */
.cta-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(174, 28, 40, 0.3);
background-color: var(--dutch-red);
color: var(--dutch-white);
}
/* 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;
}
}
@media (max-width: 480px) {
.hero-section {
padding: 40px 15px 30px;
}
.hero-section h1 {
font-size: 2rem;
font-size: 1.75rem;
}
.hero-section p {
.hero-subtitle {
font-size: 0.95rem;
}
.features-header h2,
.use-cases-section h2,
.cta-section h2 {
font-size: 1.5rem;
}
.pricing-calculator {
padding: 40px 15px;
}
.pricing-calculator h2 {
font-size: 1.5rem;
}
.calculator-subtitle {
font-size: 0.9rem;
margin-bottom: 2rem;
}
.hero-ctas .btn-primary, .hero-ctas .btn-outline {
width: 100%;
box-sizing: border-box;
.storage-display {
margin-bottom: 1.5rem;
}
.slider-container {
margin-bottom: 2rem;
}
.features-section,
.use-cases-section {
padding: 40px 15px;
}
.cta-section {
padding: 40px 15px;
}
.cta-section h2 {
font-size: 1.75rem;
}
.cta-section p {
font-size: 1rem;
}
}
@media (max-width: 360px) {
.hero-section {
padding: 30px 10px 20px;
}
.hero-section h1 {
font-size: 1.5rem;
margin-bottom: 0.75rem;
}
.hero-subtitle {
font-size: 0.875rem;
}
.pricing-calculator {
padding: 30px 10px;
}
.pricing-calculator h2 {
font-size: 1.25rem;
margin-bottom: 0.5rem;
}
.calculator-subtitle {
font-size: 0.85rem;
margin-bottom: 1.5rem;
}
.storage-display {
margin-bottom: 1rem;
}
.slider-container {
margin-bottom: 1.5rem;
}
.price-display {
margin-bottom: 1.5rem;
padding: 0.75rem;
}
.features-section,
.use-cases-section {
padding: 30px 10px;
}
.features-grid {
gap: 1.5rem;
}
.feature-card {
padding: 1.5rem;
}
.use-cases-grid {
gap: 1.5rem;
}
.use-case-card {
padding: 1.5rem;
}
.cta-section {
padding: 30px 10px;
}
.cta-section h2 {
font-size: 1.5rem;
}
.cta-section p {
font-size: 0.9rem;
margin-bottom: 1.5rem;
}
}
+42 -13
View File
@@ -4,31 +4,33 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
padding: 20px;
justify-content: flex-start;
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
box-sizing: border-box;
width: 100%;
max-width: 100%;
}
.login-page-container h1 {
font-size: 2.5rem;
font-size: clamp(1.75rem, 4vw, 2.5rem);
color: var(--text-color);
margin-bottom: 10px;
text-align: center;
}
.login-page-container .subtitle {
font-size: 1.1rem;
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--light-text-color);
margin-bottom: 30px;
margin-bottom: clamp(20px, 4vh, 30px);
text-align: center;
}
.form-container {
max-width: 450px; /* Adjust as needed */
max-width: 450px;
width: 100%;
padding: 2.5rem;
padding: clamp(1.5rem, 4vw, 2.5rem);
box-sizing: border-box;
margin-bottom: clamp(20px, 4vh, 30px);
}
.forgot-password-link {
@@ -42,7 +44,7 @@
}
.forgot-password-link:hover {
color: var(--accent-color);
color: var(--dutch-red);
text-decoration: underline;
}
@@ -60,19 +62,46 @@
}
.create-account-link a:hover {
color: var(--accent-color);
color: var(--dutch-red);
text-decoration: underline;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.login-page-container {
padding: 30px 15px 40px;
}
.login-page-container h1 {
font-size: 2rem;
font-size: 1.75rem;
}
.login-page-container .subtitle {
font-size: 1rem;
font-size: 0.95rem;
margin-bottom: 20px;
}
.form-container {
padding: 2rem;
padding: 1.5rem;
}
}
@media (max-width: 360px) {
.login-page-container {
padding: 20px 10px 30px;
}
.login-page-container h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.login-page-container .subtitle {
font-size: 0.875rem;
margin-bottom: 15px;
}
.form-container {
padding: 1rem;
}
.forgot-password-link {
font-size: 0.85rem;
}
.create-account-link {
font-size: 0.85rem;
}
}
+55
View File
@@ -373,4 +373,59 @@
flex-grow: unset;
width: 100%;
}
}
@media (max-width: 360px) {
.overview-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.quota-overview-card {
padding: 10px;
}
.quota-overview-card h2 {
font-size: 1rem;
}
.quota-overview-card .subtitle {
font-size: 0.8rem;
}
.donut-chart-container {
width: 120px;
height: 120px;
}
.donut-chart-container::before {
width: 90px;
height: 90px;
}
.donut-chart-text {
font-size: 0.85rem;
}
.order-form-card {
padding: 10px;
}
.order-form-card h3 {
font-size: 1rem;
}
.user-quotas-section {
padding: 10px;
}
.user-quotas-header h2 {
font-size: 1rem;
}
.user-quota-list {
grid-template-columns: 1fr;
gap: 15px;
}
.user-quota-item {
padding: 15px;
}
.user-quota-item .user-info h4 {
font-size: 1rem;
}
.modal-content {
padding: 15px;
}
.modal-content h3 {
font-size: 1.1rem;
}
}
-176
View File
@@ -1,176 +0,0 @@
/* Styles for the Pricing Page (pricing.html) */
.pricing-hero {
text-align: center;
padding: 40px 20px;
margin-bottom: 40px;
}
.pricing-hero h1 {
font-size: 3rem;
color: var(--text-color);
margin-bottom: 1rem;
}
.pricing-hero p {
font-size: 1.2rem;
color: var(--light-text-color);
margin-bottom: 30px;
}
.pricing-toggle {
display: inline-flex;
border-radius: 5px;
overflow: hidden;
border: 1px solid var(--border-color);
}
.pricing-toggle .btn-toggle {
padding: 10px 20px;
border: none;
background-color: var(--card-background);
color: var(--text-color);
font-size: 1rem;
cursor: pointer;
transition: background-color 0.3s ease, color 0.3s ease;
}
.pricing-toggle .btn-toggle.active {
background-color: var(--primary-color);
color: white;
}
.pricing-toggle .btn-toggle:hover:not(.active) {
background-color: var(--background-color);
}
.pricing-tiers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
max-width: 1200px;
margin: 0 auto 60px auto;
padding: 0 20px;
}
.pricing-card {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 4px 15px var(--shadow-color);
padding: 30px;
text-align: center;
display: flex;
flex-direction: column;
justify-content: space-between;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.pricing-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.pricing-card.featured {
border: 2px solid var(--primary-color);
transform: scale(1.02);
}
.pricing-card h3 {
font-size: 1.8rem;
color: var(--text-color);
margin-bottom: 15px;
}
.pricing-card .price {
font-size: 3.5rem;
font-weight: 700;
color: var(--primary-color);
margin-bottom: 20px;
}
.pricing-card .price span {
font-size: 1.2rem;
font-weight: 400;
color: var(--light-text-color);
}
.pricing-card ul {
list-style: none;
padding: 0;
margin-bottom: 30px;
text-align: left;
}
.pricing-card ul li {
margin-bottom: 10px;
color: var(--text-color);
font-size: 1rem;
display: flex;
align-items: center;
}
.pricing-card ul li::before {
content: 'âś“'; /* Checkmark icon */
color: var(--accent-color);
margin-right: 10px;
font-weight: bold;
}
.pricing-card .btn-primary {
margin-top: auto; /* Push button to the bottom */
width: 100%;
padding: 12px 20px;
font-size: 1.1rem;
}
.pricing-faq {
max-width: 800px;
margin: 0 auto 60px auto;
padding: 0 20px;
}
.pricing-faq h2 {
text-align: center;
font-size: 2.5rem;
color: var(--text-color);
margin-bottom: 40px;
}
.faq-item {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 2px 10px var(--shadow-color);
padding: 20px;
margin-bottom: 20px;
}
.faq-item h3 {
font-size: 1.3rem;
color: var(--text-color);
margin-bottom: 10px;
}
.faq-item p {
font-size: 1rem;
color: var(--light-text-color);
line-height: 1.6;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.pricing-hero h1 {
font-size: 2.5rem;
}
.pricing-hero p {
font-size: 1rem;
}
.pricing-tiers {
grid-template-columns: 1fr;
}
.pricing-card.featured {
transform: none; /* Remove scale on small screens */
}
.pricing-faq h2 {
font-size: 2rem;
}
}
+44 -15
View File
@@ -4,31 +4,33 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
padding: 20px;
justify-content: flex-start;
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
box-sizing: border-box;
width: 100%;
max-width: 100%;
}
.register-page-container h1 {
font-size: 2.5rem;
font-size: clamp(1.75rem, 4vw, 2.5rem);
color: var(--text-color);
margin-bottom: 10px;
text-align: center;
}
.register-page-container .subtitle {
font-size: 1.1rem;
font-size: clamp(0.95rem, 2vw, 1.1rem);
color: var(--light-text-color);
margin-bottom: 30px;
margin-bottom: clamp(20px, 4vh, 30px);
text-align: center;
}
.form-container {
max-width: 450px; /* Adjust as needed */
max-width: 450px;
width: 100%;
padding: 2.5rem;
padding: clamp(1.5rem, 4vw, 2.5rem);
box-sizing: border-box;
margin-bottom: clamp(20px, 4vh, 30px);
}
.terms-checkbox {
@@ -44,16 +46,16 @@
margin-right: 10px;
width: 18px;
height: 18px;
accent-color: var(--accent-color); /* Style checkbox with accent color */
accent-color: var(--dutch-red);
}
.terms-checkbox a {
color: var(--primary-color);
color: var(--dutch-blue);
font-weight: 500;
}
.terms-checkbox a:hover {
color: var(--accent-color);
color: var(--dutch-red);
text-decoration: underline;
}
@@ -71,19 +73,46 @@
}
.login-link a:hover {
color: var(--accent-color);
color: var(--dutch-red);
text-decoration: underline;
}
/* Responsive adjustments */
@media (max-width: 600px) {
.register-page-container {
padding: 30px 15px 40px;
}
.register-page-container h1 {
font-size: 2rem;
font-size: 1.75rem;
}
.register-page-container .subtitle {
font-size: 1rem;
font-size: 0.95rem;
margin-bottom: 20px;
}
.form-container {
padding: 2rem;
padding: 1.5rem;
}
}
@media (max-width: 360px) {
.register-page-container {
padding: 20px 10px 30px;
}
.register-page-container h1 {
font-size: 1.5rem;
margin-bottom: 8px;
}
.register-page-container .subtitle {
font-size: 0.875rem;
margin-bottom: 15px;
}
.form-container {
padding: 1rem;
}
.terms-checkbox {
font-size: 0.85rem;
}
.login-link {
font-size: 0.85rem;
}
}
-142
View File
@@ -1,142 +0,0 @@
/* Styles for the Security Page (security.html) */
.security-hero {
text-align: center;
padding: 40px 20px;
margin-bottom: 60px;
}
.security-hero h1 {
font-size: 3rem;
color: var(--text-color);
margin-bottom: 1rem;
}
.security-hero p {
font-size: 1.2rem;
color: var(--light-text-color);
max-width: 800px;
margin: 0 auto;
}
.security-pillars {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
max-width: 1200px;
margin: 0 auto 60px auto;
padding: 0 20px;
}
.pillar-card {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 4px 15px var(--shadow-color);
padding: 30px;
text-align: center;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.pillar-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.pillar-card img.icon {
width: 80px;
height: 80px;
margin-bottom: 20px;
}
.pillar-card h3 {
font-size: 1.5rem;
color: var(--text-color);
margin-bottom: 15px;
}
.pillar-card p {
font-size: 1rem;
color: var(--light-text-color);
line-height: 1.6;
}
.security-certifications {
text-align: center;
margin-bottom: 60px;
}
.security-certifications h2 {
font-size: 2.5rem;
color: var(--text-color);
margin-bottom: 40px;
}
.cert-grid {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 40px;
}
.cert-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.cert-item img.cert-logo {
height: 60px;
width: auto;
}
.cert-item p {
font-size: 1.1rem;
color: var(--text-color);
font-weight: 500;
}
.security-cta {
text-align: center;
padding: 40px 20px;
background-color: var(--background-color);
border-radius: 8px;
margin-bottom: 40px;
}
.security-cta h2 {
font-size: 2rem;
color: var(--text-color);
margin-bottom: 30px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.security-hero h1 {
font-size: 2.5rem;
}
.security-hero p {
font-size: 1rem;
}
.security-certifications h2 {
font-size: 2rem;
}
.security-cta h2 {
font-size: 1.8rem;
}
}
@media (max-width: 480px) {
.security-hero h1 {
font-size: 2rem;
}
.pillar-card {
padding: 20px;
}
.cert-grid {
gap: 20px;
}
.cert-item img.cert-logo {
height: 50px;
}
}
-135
View File
@@ -1,135 +0,0 @@
/* Styles for the Solutions Page (solutions.html) */
.solutions-hero {
text-align: center;
padding: 40px 20px;
margin-bottom: 60px;
}
.solutions-hero h1 {
font-size: 3rem;
color: var(--text-color);
margin-bottom: 1rem;
}
.solutions-hero p {
font-size: 1.2rem;
color: var(--light-text-color);
max-width: 800px;
margin: 0 auto;
}
.solution-sections {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
max-width: 1200px;
margin: 0 auto 60px auto;
padding: 0 20px;
}
.solution-card {
background-color: var(--card-background);
border-radius: 8px;
box-shadow: 0 4px 15px var(--shadow-color);
padding: 30px;
text-align: left;
display: flex;
flex-direction: column;
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
}
.solution-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
}
.solution-card img.icon {
width: 60px;
height: 60px;
margin-bottom: 20px;
align-self: center; /* Center the icon */
}
.solution-card h2 {
font-size: 1.8rem;
color: var(--text-color);
margin-bottom: 15px;
text-align: center;
}
.solution-card p {
font-size: 1rem;
color: var(--light-text-color);
line-height: 1.6;
margin-bottom: 20px;
}
.solution-card ul {
list-style: none;
padding: 0;
margin-bottom: 20px;
}
.solution-card ul li {
margin-bottom: 8px;
color: var(--text-color);
font-size: 0.95rem;
display: flex;
align-items: flex-start;
}
.solution-card ul li::before {
content: '•'; /* Bullet point */
color: var(--accent-color);
margin-right: 10px;
font-weight: bold;
font-size: 1.2em;
line-height: 1;
}
.solution-card .btn-primary {
margin-top: auto; /* Push button to the bottom */
align-self: center; /* Center the button */
padding: 10px 20px;
font-size: 1rem;
}
.solutions-cta {
text-align: center;
padding: 40px 20px;
background-color: var(--background-color);
border-radius: 8px;
margin-bottom: 40px;
}
.solutions-cta h2 {
font-size: 2rem;
color: var(--text-color);
margin-bottom: 30px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.solutions-hero h1 {
font-size: 2.5rem;
}
.solutions-hero p {
font-size: 1rem;
}
.solution-sections {
grid-template-columns: 1fr;
}
.solutions-cta h2 {
font-size: 1.8rem;
}
}
@media (max-width: 480px) {
.solutions-hero h1 {
font-size: 2rem;
}
.solution-card {
padding: 20px;
}
}
+46
View File
@@ -169,7 +169,53 @@
.support-hero h1, .support-categories h2, .contact-options h2 {
font-size: 1.2rem;
}
.support-hero p {
font-size: 0.85rem;
}
.category-card, .contact-card {
padding: 15px;
}
.search-support .search-input {
font-size: 0.85rem;
}
}
@media (max-width: 360px) {
.support-hero {
padding: 0;
margin-bottom: 20px;
}
.support-hero h1, .support-categories h2, .contact-options h2 {
font-size: 1.1rem;
}
.support-hero p {
font-size: 0.8rem;
}
.search-support {
max-width: 100%;
margin-bottom: 20px;
}
.search-support .search-input {
padding: 8px 12px;
font-size: 0.8rem;
}
.support-categories {
margin-bottom: 30px;
}
.category-grid, .contact-grid {
grid-template-columns: 1fr;
gap: 15px;
}
.category-card, .contact-card {
padding: 12px;
}
.category-card h3, .contact-card h3 {
font-size: 1rem;
}
.category-card p, .contact-card p {
font-size: 0.85rem;
}
.contact-options {
margin-bottom: 30px;
}
}
+52 -9
View File
@@ -2,18 +2,18 @@
.use-cases-hero {
text-align: center;
padding: 40px 20px;
margin-bottom: 60px;
padding: clamp(20px, 5vw, 40px) clamp(10px, 3vw, 20px);
margin-bottom: clamp(30px, 6vw, 60px);
}
.use-cases-hero h1 {
font-size: 3rem;
font-size: clamp(1.75rem, 5vw, 3rem);
color: var(--text-color);
margin-bottom: 1rem;
}
.use-cases-hero p {
font-size: 1.2rem;
font-size: clamp(0.95rem, 2.5vw, 1.2rem);
color: var(--light-text-color);
max-width: 800px;
margin: 0 auto;
@@ -21,11 +21,11 @@
.use-case-scenarios {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
gap: clamp(20px, 3vw, 30px);
max-width: 1200px;
margin: 0 auto 60px auto;
padding: 0 20px;
margin: 0 auto clamp(30px, 6vw, 60px) auto;
padding: 0 clamp(10px, 3vw, 20px);
}
.scenario-card {
@@ -127,9 +127,52 @@
@media (max-width: 480px) {
.use-cases-hero h1 {
font-size: 2rem;
font-size: 1.75rem;
}
.use-cases-hero p {
font-size: 0.9rem;
}
.scenario-card {
padding: 20px;
}
.scenario-card h2 {
font-size: 1.5rem;
}
.use-cases-cta h2 {
font-size: 1.5rem;
}
}
@media (max-width: 360px) {
.use-cases-hero {
padding: 15px 10px;
margin-bottom: 20px;
}
.use-cases-hero h1 {
font-size: 1.5rem;
}
.use-cases-hero p {
font-size: 0.85rem;
}
.use-case-scenarios {
gap: 20px;
padding: 0 10px;
grid-template-columns: 1fr;
min-width: 0;
}
.scenario-card {
padding: 15px;
}
.scenario-card h2 {
font-size: 1.25rem;
}
.scenario-card p {
font-size: 0.9rem;
}
.use-cases-cta {
padding: 20px 10px;
}
.use-cases-cta h2 {
font-size: 1.25rem;
}
}
+2 -2
View File
@@ -88,9 +88,9 @@ document.addEventListener('DOMContentLoaded', () => {
</div>
<div class="quota-actions">
<button class="btn-outline edit-quota-btn" data-email="${user.email}" data-quota="${user.storage_quota_gb}">Edit Quota</button>
<button class="btn-outline delete-user-btn" data-email="${user.email}">Delete User</button>
<button class="btn-outline delete-user-btn" data-email="${user.email}">🗑️</button>
<button class="btn-outline view-details-btn" data-email="${user.email}">View Details</button>
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">Delete Team</button>` : ''}
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">🗑️</button>` : ''}
</div>
`;
userQuotaList.appendChild(quotaItem);
+79
View File
@@ -0,0 +1,79 @@
class PricingCalculator {
constructor() {
this.slider = document.getElementById('storageSlider');
this.storageValue = document.getElementById('storageValue');
this.priceValue = document.getElementById('priceValue');
this.planDescription = document.getElementById('planDescription');
this.pricingTiers = [
{ maxGB: 10, price: 0, name: 'Free Plan' },
{ maxGB: 100, price: 9, name: 'Personal Plan' },
{ maxGB: 500, price: 29, name: 'Professional Plan' },
{ maxGB: 1000, price: 49, name: 'Business Plan' },
{ maxGB: 2000, price: 99, name: 'Enterprise Plan' }
];
this.init();
}
init() {
if (!this.slider) return;
this.slider.addEventListener('input', () => this.updatePrice());
this.updatePrice();
}
calculatePrice(storageGB) {
for (let tier of this.pricingTiers) {
if (storageGB <= tier.maxGB) {
return {
price: tier.price,
plan: tier.name
};
}
}
return {
price: 99,
plan: 'Enterprise Plan'
};
}
formatStorage(value) {
if (value >= 1000) {
return `${(value / 1000).toFixed(1)} TB`;
}
return `${value} GB`;
}
updatePrice() {
const storage = parseInt(this.slider.value);
const pricing = this.calculatePrice(storage);
if (storage >= 1000) {
this.storageValue.textContent = (storage / 1000).toFixed(1);
this.storageValue.nextElementSibling.textContent = 'TB';
} else {
this.storageValue.textContent = storage;
this.storageValue.nextElementSibling.textContent = 'GB';
}
this.priceValue.textContent = pricing.price;
this.planDescription.textContent = pricing.plan;
}
}
class Application {
constructor() {
this.pricingCalculator = null;
this.init();
}
init() {
document.addEventListener('DOMContentLoaded', () => {
this.pricingCalculator = new PricingCalculator();
});
}
}
const app = new Application();
export default app;
+88 -49
View File
@@ -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 = `
<div class="file-name">${file.name}</div>
<div class="progress-bar-wrapper">
<div class="progress-bar" id="progress-${file.name.replace(/\./g, '-')}" style="width: 0%;"></div>
</div>
<div class="progress-text" id="progress-text-${file.name.replace(/\./g, '-')}" >0%</div>
`;
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 = `
<div class="file-name">${file.name}</div>
<div class="progress-bar-wrapper">
<div class="progress-bar" id="progress-${file.name.replace(/\./g, '-')}" style="width: 0%;"></div>
</div>
<div class="progress-text" id="progress-text-${file.name.replace(/\./g, '-')}">0%</div>
`;
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);
}
});
+2 -2
View File
@@ -34,8 +34,8 @@ document.addEventListener('DOMContentLoaded', () => {
<div class="quota-actions">
<a href="/users/${user.email}/details" class="btn-outline">View Details</a>
<a href="/users/${user.email}/edit" class="btn-outline">Edit Quota</a>
<button class="btn-outline delete-user-btn" data-email="${user.email}">Delete User</button>
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">Delete Team</button>` : ''}
<button class="btn-outline delete-user-btn" data-email="${user.email}">🗑️</button>
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">🗑️</button>` : ''}
</div>
`;
userQuotaList.appendChild(quotaItem);
+72 -51
View File
@@ -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');
}
}
@@ -109,63 +115,65 @@ document.addEventListener('DOMContentLoaded', () => {
async function shareFile(paths, names) {
const modal = document.getElementById('share-modal');
const linkContainer = document.getElementById('share-link-container');
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 quickShareResult = document.getElementById('quick-share-result');
const quickShareLinkInput = document.getElementById('quick-share-link-input');
const generateQuickShareBtn = document.getElementById('generate-quick-share-btn');
const advancedShareBtn = document.getElementById('advanced-share-btn');
// Clear previous content
shareLinkInput.value = '';
if (shareLinksList) shareLinksList.innerHTML = '';
linkContainer.style.display = 'none';
loading.style.display = 'block';
modal.style.display = 'block';
quickShareResult.style.display = 'none';
quickShareLinkInput.value = '';
modal.classList.add('show');
const currentPath = paths[0];
const currentName = names[0];
if (paths.length === 1) {
shareFileName.textContent = `Sharing: ${names[0]}`;
shareFileName.textContent = `Sharing: ${currentName}`;
} else {
shareFileName.textContent = `Sharing ${paths.length} items`;
}
try {
const response = await fetch(`/files/share_multiple`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ paths: paths })
});
const data = await response.json();
generateQuickShareBtn.onclick = async function() {
generateQuickShareBtn.disabled = true;
generateQuickShareBtn.textContent = 'Generating...';
if (data.share_links && data.share_links.length > 0) {
if (data.share_links.length === 1) {
shareLinkInput.value = data.share_links[0];
linkContainer.style.display = 'block';
try {
const response = await fetch('/api/sharing/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
item_path: currentPath,
permission: 'view',
scope: 'public',
expiration_days: 7
})
});
const data = await response.json();
if (data.success) {
quickShareLinkInput.value = data.share_url;
quickShareResult.style.display = 'block';
generateQuickShareBtn.textContent = 'Generate Another Link';
} else {
// Display multiple links
if (!shareLinksList) {
// Create the list if it doesn't exist
const ul = document.createElement('ul');
ul.id = 'share-links-list';
linkContainer.appendChild(ul);
shareLinksList = ul;
}
data.share_links.forEach(item => {
const li = document.createElement('li');
li.innerHTML = `<strong>${item.name}:</strong> <input type="text" value="${item.link}" readonly class="form-input share-link-item-input"> <button class="btn-primary copy-share-link-item-btn" data-link="${item.link}">Copy</button>`;
shareLinksList.appendChild(li);
});
linkContainer.style.display = 'block';
alert('Error generating share link: ' + data.error);
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
}
loading.style.display = 'none';
} else {
loading.textContent = 'Error generating share link(s)';
} catch (error) {
console.error('Error generating share link:', error);
alert('Error generating share link');
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
} finally {
generateQuickShareBtn.disabled = false;
}
} catch (error) {
console.error('Error sharing files:', error);
loading.textContent = 'Error generating share link(s)';
}
};
advancedShareBtn.onclick = function() {
window.location.href = `/sharing/create?item_path=${encodeURIComponent(currentPath)}`;
};
}
function copyShareLink() {
@@ -180,7 +188,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 +206,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
@@ -291,6 +298,20 @@ document.addEventListener('DOMContentLoaded', () => {
copyShareLinkBtn.addEventListener('click', copyShareLink);
}
const copyQuickShareBtn = document.getElementById('copy-quick-share-btn');
if (copyQuickShareBtn) {
copyQuickShareBtn.addEventListener('click', function() {
const input = document.getElementById('quick-share-link-input');
input.select();
navigator.clipboard.writeText(input.value).then(() => {
alert('Share link copied to clipboard');
}).catch(err => {
document.execCommand('copy');
alert('Share link copied to clipboard');
});
});
}
document.getElementById('select-all')?.addEventListener('change', function(e) {
const checkboxes = document.querySelectorAll('.file-checkbox');
checkboxes.forEach(cb => cb.checked = e.target.checked);
@@ -0,0 +1,228 @@
<div id="cookie-banner" class="cookie-banner" style="display: none;">
<div class="cookie-banner-content">
<div class="cookie-banner-text">
<h3>Cookie Settings</h3>
<p>We use cookies to provide essential website functionality and improve your experience. For more information, please read our <a href="/cookies">Cookie Policy</a>.</p>
</div>
<div class="cookie-banner-actions">
<button id="cookie-reject" class="btn-outline">Reject All</button>
<button id="cookie-customize" class="btn-outline">Customize</button>
<button id="cookie-accept" class="btn-primary">Accept All</button>
</div>
</div>
</div>
<div id="cookie-preferences-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" id="close-cookie-modal">&times;</span>
<h3>Cookie Preferences</h3>
<p>Manage your cookie preferences below. Some cookies are essential for the website to function and cannot be disabled.</p>
<div class="cookie-category">
<div class="cookie-category-header">
<label>
<input type="checkbox" id="cookie-necessary" checked disabled>
<strong>Strictly Necessary Cookies</strong>
</label>
</div>
<p>These cookies are essential for the website to function properly. They enable core functionality such as security and session management.</p>
</div>
<div class="cookie-category">
<div class="cookie-category-header">
<label>
<input type="checkbox" id="cookie-functional">
<strong>Functional Cookies</strong>
</label>
</div>
<p>These cookies allow the website to remember your preferences and provide enhanced features.</p>
</div>
<div class="cookie-category">
<div class="cookie-category-header">
<label>
<input type="checkbox" id="cookie-analytics">
<strong>Analytics Cookies</strong>
</label>
</div>
<p>These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously.</p>
</div>
<div class="modal-actions">
<button id="save-cookie-preferences" class="btn-primary">Save Preferences</button>
<button id="cancel-cookie-preferences" class="btn-outline">Cancel</button>
</div>
</div>
</div>
<style>
.cookie-banner {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background-color: var(--card-background);
border-top: 2px solid var(--border-color);
padding: 20px;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.3);
z-index: 1000;
}
.cookie-banner-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
gap: 20px;
}
.cookie-banner-text {
flex: 1;
}
.cookie-banner-text h3 {
margin: 0 0 10px 0;
color: var(--text-color);
}
.cookie-banner-text p {
margin: 0;
color: var(--light-text-color);
font-size: 0.9rem;
}
.cookie-banner-text a {
color: var(--accent-color);
text-decoration: underline;
}
.cookie-banner-actions {
display: flex;
gap: 10px;
}
.cookie-category {
margin-bottom: 20px;
padding: 15px;
background-color: var(--background-color);
border-radius: 4px;
border: 1px solid var(--border-color);
}
.cookie-category-header {
margin-bottom: 10px;
}
.cookie-category-header label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.cookie-category-header input[type="checkbox"] {
cursor: pointer;
}
.cookie-category p {
margin: 0;
font-size: 0.85rem;
color: var(--light-text-color);
}
@media (max-width: 768px) {
.cookie-banner-content {
flex-direction: column;
align-items: flex-start;
}
.cookie-banner-actions {
width: 100%;
flex-direction: column;
}
.cookie-banner-actions button {
width: 100%;
}
}
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
const banner = document.getElementById('cookie-banner');
const acceptBtn = document.getElementById('cookie-accept');
const rejectBtn = document.getElementById('cookie-reject');
const customizeBtn = document.getElementById('cookie-customize');
const modal = document.getElementById('cookie-preferences-modal');
const closeModal = document.getElementById('close-cookie-modal');
const savePreferences = document.getElementById('save-cookie-preferences');
const cancelPreferences = document.getElementById('cancel-cookie-preferences');
function getCookieConsent() {
return localStorage.getItem('cookie-consent');
}
function setCookieConsent(value) {
localStorage.setItem('cookie-consent', value);
}
function getCookiePreferences() {
const prefs = localStorage.getItem('cookie-preferences');
return prefs ? JSON.parse(prefs) : { necessary: true, functional: false, analytics: false };
}
function setCookiePreferences(prefs) {
localStorage.setItem('cookie-preferences', JSON.stringify(prefs));
}
if (!getCookieConsent()) {
banner.style.display = 'block';
}
acceptBtn.addEventListener('click', function() {
setCookieConsent('accepted');
setCookiePreferences({ necessary: true, functional: true, analytics: true });
banner.style.display = 'none';
});
rejectBtn.addEventListener('click', function() {
setCookieConsent('rejected');
setCookiePreferences({ necessary: true, functional: false, analytics: false });
banner.style.display = 'none';
});
customizeBtn.addEventListener('click', function() {
const prefs = getCookiePreferences();
document.getElementById('cookie-functional').checked = prefs.functional;
document.getElementById('cookie-analytics').checked = prefs.analytics;
modal.style.display = 'block';
});
closeModal.addEventListener('click', function() {
modal.style.display = 'none';
});
cancelPreferences.addEventListener('click', function() {
modal.style.display = 'none';
});
savePreferences.addEventListener('click', function() {
const prefs = {
necessary: true,
functional: document.getElementById('cookie-functional').checked,
analytics: document.getElementById('cookie-analytics').checked
};
setCookieConsent('customized');
setCookiePreferences(prefs);
modal.style.display = 'none';
banner.style.display = 'none';
});
window.addEventListener('click', function(event) {
if (event.target === modal) {
modal.style.display = 'none';
}
});
});
</script>
+29 -1
View File
@@ -1,3 +1,31 @@
<footer aria-label="Site information and copyright">
<p>&copy; 2025 Retoors. All rights reserved.</p>
<div class="footer-content">
<div class="footer-section">
<h4>Legal</h4>
<ul>
<li><a href="/privacy">Privacy Policy</a></li>
<li><a href="/cookies">Cookie Policy</a></li>
<li><a href="/terms">Terms of Service</a></li>
<li><a href="/impressum">Impressum</a></li>
</ul>
</div>
<div class="footer-section">
<h4>Policies</h4>
<ul>
<li><a href="/aup">Acceptable Use Policy</a></li>
<li><a href="/sla">Service Level Agreement</a></li>
<li><a href="/compliance">Security & Compliance</a></li>
</ul>
</div>
<div class="footer-section">
<h4>User Rights</h4>
<ul>
<li><a href="/user_rights">Data Access & Deletion</a></li>
<li><a href="/support">Contact Support</a></li>
</ul>
</div>
</div>
<div class="footer-bottom">
<p>&copy; 2025 Retoors. All rights reserved.</p>
</div>
</footer>
+21 -20
View File
@@ -1,22 +1,23 @@
<header>
<nav aria-label="Main navigation">
<a href="/" class="logo" aria-label="HomeBase Storage home">
<img src="/static/images/retoors-logo.svg" alt="HomeBase Storage" />
<span>HomeBase Storage</span>
</a>
<ul class="nav-links">
<li><a href="/solutions" aria-label="Our Solutions">Solutions</a></li>
<li><a href="/pricing" aria-label="Pricing Plans">Pricing</a></li>
<li><a href="/security" aria-label="Security Information">Security</a></li>
<li><a href="/support" aria-label="Support Page">Support</a></li>
{% if request['user'] %}
<li><a href="/dashboard" aria-label="User Dashboard">Dashboard</a></li>
<li><a href="/files" aria-label="File Browser">File Browser</a></li>
<li><a href="/logout" class="btn-primary-nav" aria-label="Logout">Logout</a></li>
{% else %}
<li><a href="/login" class="btn-outline-nav" aria-label="Sign In to your account">Sign In</a></li>
<li><a href="/register" class="btn-primary-nav" aria-label="Start your free trial">Start Your Free Trial</a></li>
{% endif %}
</ul>
<header class="site-header">
<nav class="site-nav" aria-label="Main navigation">
<div class="nav-container">
<a href="/" class="brand" aria-label="Retoor's Cloud Solutions home">
<span class="brand-text">Retoor's</span>
</a>
<ul class="nav-menu">
{% if request.get('user') %}
<li><a href="/files">My Files</a></li>
{% endif %}
</ul>
<div class="nav-actions">
{% if request.get('user') %}
<a href="/files" class="nav-link">Dashboard</a>
<a href="/logout" class="btn-outline">Logout</a>
{% else %}
<a href="/login" class="nav-link">Sign In</a>
<a href="/register" class="btn-primary">Get Started Free</a>
{% endif %}
</div>
</div>
</nav>
</header>
+5 -6
View File
@@ -3,18 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Retoors Storage{% endblock %}</title>
<title>{% block title %}Retoors Cloud Solutions{% endblock %}</title>
<link rel="stylesheet" href="/static/css/base.css">
<link rel="stylesheet" href="/static/css/components/footer.css">
<link rel="stylesheet" href="/static/css/components/content_pages.css"> {# Added for content page styling #}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% block head %}{% endblock %}
</head>
<body>
<div class="container">
{% block content %}{% endblock %}
</div>
{% include 'components/navigation.html' %}
{% block content %}{% endblock %}
{% include 'components/footer.html' %}
{% include 'components/cookie_banner.html' %}
<script src="/static/js/main.js" type="module"></script>
{% block scripts %}{% endblock %}
</body>
+1
View File
@@ -11,6 +11,7 @@
<div class="sidebar-menu">
<ul>
<li><a href="/files" {% if active_page == 'files' %}class="active"{% endif %}><img src="/static/images/icon-families.svg" alt="My Files Icon" class="icon"> My Files</a></li>
<li><a href="/sharing/manage" {% if active_page == 'my_shares' %}class="active"{% endif %}><img src="/static/images/icon-professionals.svg" alt="My Shares Icon" class="icon"> My Shares</a></li>
<li><a href="/shared" {% if active_page == 'shared' %}class="active"{% endif %}><img src="/static/images/icon-professionals.svg" alt="Shared Icon" class="icon"> Shared with me</a></li>
<li><a href="/recent" {% if active_page == 'recent' %}class="active"{% endif %}><img src="/static/images/icon-students.svg" alt="Recent Icon" class="icon"> Recent</a></li>
<li><a href="/favorites" {% if active_page == 'favorites' %}class="active"{% endif %}><img src="/static/images/icon-families.svg" alt="Favorites Icon" class="icon"> Favorites</a></li>
+94
View File
@@ -0,0 +1,94 @@
{% extends "layouts/base.html" %}
{% block title %}Acceptable Use Policy{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>Acceptable Use Policy</h1>
<p>Last updated: January 2025</p>
<p>This Acceptable Use Policy governs your use of Retoor's Cloud Solutions services. By using our services, you agree to comply with this policy.</p>
<h2>1. Prohibited Content</h2>
<p>You may not use our services to store, share, or distribute content that:</p>
<ul>
<li>Is illegal under Dutch or EU law</li>
<li>Contains malware, viruses, or other harmful code</li>
<li>Infringes intellectual property rights, including copyright, trademark, or patent rights</li>
<li>Contains child sexual abuse material or exploitation content</li>
<li>Promotes terrorism, violence, or hatred</li>
<li>Contains personal data of others without proper authorization</li>
<li>Is defamatory, fraudulent, or deceptive</li>
<li>Violates the privacy or data protection rights of others</li>
</ul>
<h2>2. Prohibited Activities</h2>
<p>You may not use our services to:</p>
<ul>
<li>Attempt to gain unauthorized access to our systems or other users' accounts</li>
<li>Interfere with or disrupt the integrity or performance of our services</li>
<li>Attempt to decipher, decompile, or reverse engineer any software comprising our services</li>
<li>Engage in any form of automated data collection or scraping</li>
<li>Use our services to send spam, phishing attempts, or other unsolicited communications</li>
<li>Resell or redistribute our services without authorization</li>
<li>Use our services for cryptocurrency mining</li>
<li>Engage in activities that could harm our reputation or business operations</li>
</ul>
<h2>3. Resource Usage Limits</h2>
<p>Your use of our services is subject to the following limits:</p>
<ul>
<li>Storage quota as defined in your service plan</li>
<li>Reasonable bandwidth usage consistent with normal cloud storage operations</li>
<li>Maximum file size limits as specified in your plan</li>
<li>API rate limits to ensure fair usage for all users</li>
</ul>
<p>Excessive resource usage that impacts service performance for other users may result in throttling or suspension.</p>
<h2>4. Security Requirements</h2>
<p>You are responsible for:</p>
<ul>
<li>Maintaining the confidentiality of your account credentials</li>
<li>Promptly notifying us of any unauthorized access to your account</li>
<li>Using strong passwords and enabling two-factor authentication when available</li>
<li>Ensuring that any data you upload does not contain malware or viruses</li>
</ul>
<h2>5. Consequences of Violation</h2>
<p>Violation of this Acceptable Use Policy may result in:</p>
<ul>
<li>Immediate removal of prohibited content</li>
<li>Temporary suspension of your account</li>
<li>Permanent termination of your account and services</li>
<li>Referral to law enforcement authorities</li>
<li>Legal action to recover damages</li>
</ul>
<h2>6. Reporting Abuse</h2>
<p>If you become aware of any violation of this policy, please report it to us immediately:</p>
<ul>
<li>Email: abuse@retoors.nl</li>
<li>Subject line: "AUP Violation Report"</li>
<li>Include: Details of the violation, relevant URLs or account information, and any supporting evidence</li>
</ul>
<p>We will investigate all reports and take appropriate action within a reasonable timeframe.</p>
<h2>7. Investigation Rights</h2>
<p>We reserve the right to:</p>
<ul>
<li>Investigate suspected violations of this policy</li>
<li>Access and review content stored on our services when necessary to ensure compliance</li>
<li>Cooperate with law enforcement authorities</li>
<li>Remove content or suspend accounts pending investigation</li>
</ul>
<h2>8. Modifications to This Policy</h2>
<p>We may modify this Acceptable Use Policy at any time. Continued use of our services after modifications constitutes acceptance of the updated policy.</p>
<h2>9. Questions</h2>
<p>If you have questions about this policy, please contact us at: <a href="mailto:legal@retoors.nl">legal@retoors.nl</a></p>
</section>
</main>
{% endblock %}
+194
View File
@@ -0,0 +1,194 @@
{% extends "layouts/base.html" %}
{% block title %}Security & Compliance{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>Security & Compliance</h1>
<p>Retoor's Cloud Solutions is committed to maintaining the highest standards of security and compliance to protect your data and ensure regulatory adherence.</p>
<h2>1. Data Security Measures</h2>
<h3>1.1 Encryption Standards</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>Standard</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>Data in Transit</td>
<td>TLS 1.3</td>
<td>All data transmitted between your device and our servers is encrypted using the latest TLS protocol</td>
</tr>
<tr>
<td>Data at Rest</td>
<td>AES-256</td>
<td>All stored files are encrypted using industry-standard AES-256 encryption</td>
</tr>
<tr>
<td>Database</td>
<td>AES-256</td>
<td>User credentials and metadata are encrypted at the database level</td>
</tr>
</tbody>
</table>
<h3>1.2 Access Control</h3>
<ul>
<li>Multi-factor authentication (MFA) available for all accounts</li>
<li>Role-based access control (RBAC) for team accounts</li>
<li>Session management with automatic timeout</li>
<li>IP whitelisting available for Enterprise customers</li>
<li>Audit logs for all file access and modifications</li>
</ul>
<h3>1.3 Infrastructure Security</h3>
<ul>
<li>Data centers hosted by Hetzner in Germany and Finland (EU-based)</li>
<li>24/7 physical security and monitoring</li>
<li>DDoS protection and intrusion detection systems</li>
<li>Regular security audits and penetration testing</li>
<li>Automated backup systems with geographic redundancy</li>
</ul>
<h2>2. Compliance Certifications</h2>
<h3>2.1 GDPR Compliance</h3>
<p>We are fully compliant with the General Data Protection Regulation (GDPR):</p>
<ul>
<li>Data processing agreements available for all customers</li>
<li>Right to access, rectification, erasure, and portability</li>
<li>Data breach notification within 72 hours</li>
<li>Privacy by design and by default</li>
<li>All data stored within the European Union</li>
<li>No data transfers outside EU without appropriate safeguards</li>
</ul>
<h3>2.2 ISO 27001</h3>
<p>Our information security management system is aligned with ISO 27001 standards:</p>
<ul>
<li>Regular risk assessments and security reviews</li>
<li>Documented security policies and procedures</li>
<li>Employee security training and awareness programs</li>
<li>Incident response and business continuity plans</li>
</ul>
<h3>2.3 SOC 2 Type II</h3>
<p>We maintain SOC 2 Type II compliance covering:</p>
<ul>
<li>Security: Protection against unauthorized access</li>
<li>Availability: System uptime and performance</li>
<li>Confidentiality: Protection of sensitive information</li>
<li>Privacy: Handling of personal information</li>
</ul>
<h2>3. Data Center Locations</h2>
<p>Your data is stored exclusively in European Union data centers:</p>
<table>
<thead>
<tr>
<th>Location</th>
<th>Provider</th>
<th>Certifications</th>
</tr>
</thead>
<tbody>
<tr>
<td>Falkenstein, Germany</td>
<td>Hetzner Online GmbH</td>
<td>ISO 27001, PCI DSS</td>
</tr>
<tr>
<td>Helsinki, Finland</td>
<td>Hetzner Online GmbH</td>
<td>ISO 27001, PCI DSS</td>
</tr>
</tbody>
</table>
<p>All data centers feature:</p>
<ul>
<li>99.99% power availability with redundant power supplies</li>
<li>Climate-controlled environments</li>
<li>Biometric access control</li>
<li>24/7 on-site security personnel</li>
</ul>
<h2>4. Data Processing Agreement</h2>
<p>For business customers, we provide a comprehensive Data Processing Agreement (DPA) that includes:</p>
<ul>
<li>Clear definition of roles (Controller vs. Processor)</li>
<li>List of sub-processors and their locations</li>
<li>Data security measures and obligations</li>
<li>Data subject rights and assistance procedures</li>
<li>Data breach notification procedures</li>
<li>Terms for data deletion upon contract termination</li>
</ul>
<p><a href="/dpa" class="btn-primary">Download DPA Template</a></p>
<h2>5. Security Monitoring</h2>
<p>We continuously monitor our systems for security threats:</p>
<ul>
<li>Real-time threat detection and alerting</li>
<li>Automated vulnerability scanning</li>
<li>Security information and event management (SIEM)</li>
<li>Regular penetration testing by third-party security firms</li>
<li>Bug bounty program for responsible disclosure</li>
</ul>
<h2>6. Incident Response</h2>
<p>In the event of a security incident:</p>
<ul>
<li>Immediate containment and investigation</li>
<li>Notification to affected customers within 24 hours</li>
<li>Detailed incident reports provided to Business and Enterprise customers</li>
<li>Post-incident review and remediation</li>
<li>Cooperation with regulatory authorities as required</li>
</ul>
<h2>7. Employee Security</h2>
<p>All employees undergo rigorous security protocols:</p>
<ul>
<li>Background checks for all staff with data access</li>
<li>Confidentiality and non-disclosure agreements</li>
<li>Regular security awareness training</li>
<li>Principle of least privilege access</li>
<li>Secure development practices and code reviews</li>
</ul>
<h2>8. Third-Party Audits</h2>
<p>We undergo regular third-party security audits:</p>
<ul>
<li>Annual penetration testing by certified security firms</li>
<li>Quarterly vulnerability assessments</li>
<li>Independent compliance audits for ISO and SOC certifications</li>
<li>Audit reports available to Enterprise customers upon request</li>
</ul>
<h2>9. Security Best Practices for Users</h2>
<p>We recommend the following security practices:</p>
<ul>
<li>Enable two-factor authentication on your account</li>
<li>Use strong, unique passwords</li>
<li>Regularly review account activity and access logs</li>
<li>Keep your contact information up to date</li>
<li>Be cautious of phishing attempts</li>
<li>Report suspicious activity immediately</li>
</ul>
<h2>10. Questions and Reporting</h2>
<p>For security-related inquiries or to report vulnerabilities:</p>
<ul>
<li>Security Team: security@retoors.nl</li>
<li>Vulnerability Disclosure: security-disclosure@retoors.nl</li>
<li>Compliance Questions: compliance@retoors.nl</li>
</ul>
</section>
</main>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "layouts/base.html" %}
{% block title %}Cookie Policy{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>Cookie Policy</h1>
<p>Last updated: January 2025</p>
<h2>1. What Are Cookies</h2>
<p>Cookies are small text files that are placed on your device when you visit our website. They help us provide you with a better experience by remembering your preferences and understanding how you use our service.</p>
<h2>2. Cookie Categories</h2>
<h3>2.1 Strictly Necessary Cookies</h3>
<p>These cookies are essential for the website to function properly. They enable core functionality such as security, network management, and accessibility. You cannot opt out of these cookies as they are required for the service to work.</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Provider</th>
<th>Purpose</th>
<th>Duration</th>
</tr>
</thead>
<tbody>
<tr>
<td>session</td>
<td>Retoor's Cloud Solutions</td>
<td>Maintains your login session</td>
<td>Session</td>
</tr>
</tbody>
</table>
<h3>2.2 Functional Cookies</h3>
<p>These cookies allow us to remember choices you make and provide enhanced, more personalized features.</p>
<h3>2.3 Analytics Cookies</h3>
<p>These cookies help us understand how visitors interact with our website by collecting and reporting information anonymously. We use this data to improve our service.</p>
<h3>2.4 Marketing/Tracking Cookies</h3>
<p>We currently do not use marketing or tracking cookies.</p>
<h2>3. Third-Party Cookies</h2>
<p>We may use third-party services that set cookies on our behalf. These services include:</p>
<ul>
<li>Payment processors for handling transactions</li>
<li>Hosting providers (Hetzner) for infrastructure services</li>
</ul>
<h2>4. Legal Basis</h2>
<p>We use strictly necessary cookies based on our legitimate interest in providing a functional service. For all other cookies, we obtain your explicit consent before placing them on your device.</p>
<h2>5. How to Manage Cookies</h2>
<p>You can control and manage cookies in several ways:</p>
<ul>
<li>Browser settings: Most browsers allow you to refuse or accept cookies through their settings. Please note that disabling cookies may impact your experience on our website.</li>
<li>Cookie preferences: You can manage your cookie preferences using our cookie consent banner.</li>
</ul>
<h3>Browser-Specific Instructions</h3>
<ul>
<li>Chrome: Settings > Privacy and security > Cookies and other site data</li>
<li>Firefox: Settings > Privacy & Security > Cookies and Site Data</li>
<li>Safari: Preferences > Privacy > Cookies and website data</li>
<li>Edge: Settings > Privacy, search, and services > Cookies and site permissions</li>
</ul>
<h2>6. Changes to This Policy</h2>
<p>We may update this Cookie Policy from time to time. Any changes will be posted on this page with an updated revision date.</p>
<h2>7. Contact Us</h2>
<p>If you have any questions about our use of cookies, please contact us through our support page.</p>
</section>
</main>
{% endblock %}
+246
View File
@@ -0,0 +1,246 @@
{% extends "layouts/dashboard.html" %}
{% block title %}Create Share - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<link rel="stylesheet" href="/static/css/components/form.css">
<style>
.share-form-container {
max-width: 800px;
margin: 2rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.form-section {
margin-bottom: 2rem;
}
.form-section h3 {
margin-bottom: 1rem;
color: #333;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.checkbox-group input {
width: auto;
}
.recipient-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recipient-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-add-recipient {
margin-top: 0.5rem;
}
.share-url-display {
display: none;
margin-top: 2rem;
padding: 1rem;
background: #f0f7ff;
border-radius: 4px;
}
.share-url-display input {
font-family: monospace;
background: white;
}
</style>
{% endblock %}
{% block page_title %}Create Share{% endblock %}
{% block dashboard_content %}
<div class="share-form-container">
<form id="create-share-form">
<div class="form-section">
<h3>Basic Information</h3>
<div class="form-group">
<label for="item_path">Item to Share</label>
<input type="text" id="item_path" name="item_path" value="{{ item_path }}" required readonly>
</div>
<div class="form-group">
<label for="permission">Permission Level</label>
<select id="permission" name="permission">
<option value="view">View Only - Can view and download</option>
<option value="edit">Edit - Can modify, add, and delete</option>
<option value="comment">Comment - Can provide feedback only</option>
</select>
</div>
<div class="form-group">
<label for="scope">Sharing Scope</label>
<select id="scope" name="scope">
<option value="public">Public - Anyone with the link</option>
<option value="private">Private - Specific recipients only</option>
<option value="account_based">Account Based - Requires user account</option>
</select>
</div>
</div>
<div class="form-section" id="recipients-section" style="display: none;">
<h3>Recipients</h3>
<div class="form-group">
<label>Recipient Emails</label>
<div class="recipient-list" id="recipient-list">
<div class="recipient-item">
<input type="email" class="recipient-email" placeholder="email@example.com">
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
</div>
</div>
<button type="button" class="btn-outline btn-add-recipient" id="add-recipient-btn">Add Recipient</button>
</div>
</div>
<div class="form-section">
<h3>Security Options</h3>
<div class="form-group">
<label for="password">Password Protection</label>
<input type="password" id="password" name="password" placeholder="Optional password">
</div>
<div class="form-group">
<label for="expiration_days">Expiration</label>
<select id="expiration_days" name="expiration_days">
<option value="">Never expires</option>
<option value="1">1 day</option>
<option value="7" selected>7 days</option>
<option value="30">30 days</option>
<option value="90">90 days</option>
</select>
</div>
<div class="form-group checkbox-group">
<input type="checkbox" id="disable_download" name="disable_download">
<label for="disable_download">Disable downloads (view only)</label>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Create Share Link</button>
<button type="button" class="btn-outline" onclick="history.back()">Cancel</button>
</div>
</form>
<div class="share-url-display" id="share-url-display">
<h3>Share Link Created</h3>
<div class="form-group">
<label>Share URL</label>
<input type="text" id="share-url" readonly>
<button type="button" class="btn-primary" id="copy-url-btn">Copy Link</button>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
const form = document.getElementById('create-share-form');
const scopeSelect = document.getElementById('scope');
const recipientsSection = document.getElementById('recipients-section');
const addRecipientBtn = document.getElementById('add-recipient-btn');
const recipientList = document.getElementById('recipient-list');
const shareUrlDisplay = document.getElementById('share-url-display');
const shareUrlInput = document.getElementById('share-url');
const copyUrlBtn = document.getElementById('copy-url-btn');
scopeSelect.addEventListener('change', () => {
if (scopeSelect.value === 'private' || scopeSelect.value === 'account_based') {
recipientsSection.style.display = 'block';
} else {
recipientsSection.style.display = 'none';
}
});
addRecipientBtn.addEventListener('click', () => {
const recipientItem = document.createElement('div');
recipientItem.className = 'recipient-item';
recipientItem.innerHTML = `
<input type="email" class="recipient-email" placeholder="email@example.com">
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
`;
recipientList.appendChild(recipientItem);
});
recipientList.addEventListener('click', (e) => {
if (e.target.classList.contains('btn-remove-recipient')) {
e.target.parentElement.remove();
}
});
form.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
item_path: document.getElementById('item_path').value,
permission: document.getElementById('permission').value,
scope: document.getElementById('scope').value,
password: document.getElementById('password').value || null,
expiration_days: parseInt(document.getElementById('expiration_days').value) || null,
disable_download: document.getElementById('disable_download').checked,
recipient_emails: []
};
if (formData.scope === 'private' || formData.scope === 'account_based') {
const emailInputs = document.querySelectorAll('.recipient-email');
formData.recipient_emails = Array.from(emailInputs)
.map(input => input.value.trim())
.filter(email => email);
}
try {
const response = await fetch('/api/sharing/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.success) {
shareUrlInput.value = result.share_url;
shareUrlDisplay.style.display = 'block';
form.style.display = 'none';
} else {
alert('Error: ' + result.error);
}
} catch (error) {
alert('Error creating share: ' + error.message);
}
});
copyUrlBtn.addEventListener('click', () => {
shareUrlInput.select();
document.execCommand('copy');
copyUrlBtn.textContent = 'Copied!';
setTimeout(() => {
copyUrlBtn.textContent = 'Copy Link';
}, 2000);
});
</script>
{% endblock %}
+1 -1
View File
@@ -57,7 +57,7 @@
<button class="btn-outline" onclick="alert('Download feature coming soon!')">Download</button>
<button class="btn-outline" onclick="alert('Upload feature coming soon!')">Upload</button>
<button class="btn-outline" onclick="alert('Share feature coming soon!')">Share</button>
<button class="btn-outline" onclick="alert('Delete feature coming soon!')">Delete</button>
<button class="btn-outline" onclick="alert('Delete feature coming soon!')">🗑️</button>
</div>
</div>
+2 -2
View File
@@ -71,7 +71,7 @@
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
{% endif %}
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
<button class="btn-small btn-danger remove-favorite-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Remove</button>
<button class="btn-small btn-danger remove-favorite-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
</div>
</td>
</tr>
@@ -111,7 +111,7 @@
<p id="remove-favorite-message"></p>
<form id="remove-favorite-form" method="post">
<div class="modal-actions">
<button type="submit" class="btn-danger">Remove</button>
<button type="submit" class="btn-danger">🗑️</button>
<button type="button" class="btn-outline" onclick="closeModal('remove-favorite-modal')">Cancel</button>
</div>
</form>
+45 -13
View File
@@ -1,19 +1,19 @@
{% extends "layouts/dashboard.html" %}
{% block title %}My Files - Retoor's Cloud Solutions{% endblock %}
{% block title %}{% if current_path %}{{ current_path.split('/')[-1] }} - Retoor's Cloud Solutions{% else %}My Files - Retoor's Cloud Solutions{% endif %}{% endblock %}
{% block dashboard_head %}
<link rel="stylesheet" href="/static/css/components/file_browser.css">
{% endblock %}
{% block page_title %}My Files{% endblock %}
{% block page_title %}{% if current_path %}{{ current_path.split('/')[-1] }}{% else %}My Files{% endif %}{% endblock %}
{% block dashboard_actions %}
<button class="btn-primary" id="new-folder-btn">+ New</button>
<button class="btn-outline" id="upload-btn">Upload</button>
<button class="btn-outline" id="download-selected-btn" disabled>⬇️</button>
<button class="btn-outline" id="share-selected-btn" disabled>đź”—</button>
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</button>
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
{% endblock %}
{% block dashboard_content %}
@@ -55,7 +55,13 @@
<a href="/files?path={{ item.path }}">{{ item.name }}</a>
{% else %}
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
{{ item.name }}
{% if item.is_editable %}
<a href="/editor?path={{ item.path }}">{{ item.name }}</a>
{% elif item.is_viewable %}
<a href="/viewer?path={{item.path}}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
{% endif %}
</td>
<td>{{ user.email }}</td>
@@ -73,7 +79,7 @@
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
{% endif %}
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
</div>
</td>
</tr>
@@ -125,20 +131,46 @@
<div id="share-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('share-modal')">&times;</span>
<h3>Share File</h3>
<h3>Share Item</h3>
<p id="share-file-name"></p>
<div id="share-link-container" style="display: none;">
<input type="text" id="share-link-input" readonly class="form-input">
<button class="btn-primary" id="copy-share-link-btn">Copy Link</button>
<div id="share-links-list" class="share-links-list"></div>
<div class="share-options">
<h4>Quick Share (Public Link)</h4>
<p style="color: #666; font-size: 0.9em; margin-bottom: 1rem;">Generate a simple public link that anyone can access</p>
<div id="quick-share-container">
<button class="btn-primary" id="generate-quick-share-btn">Generate Quick Share Link</button>
<div id="quick-share-result" style="display: none; margin-top: 1rem;">
<input type="text" id="quick-share-link-input" readonly class="form-input">
<button class="btn-outline" id="copy-quick-share-btn">Copy Link</button>
</div>
</div>
<hr style="margin: 2rem 0;">
<h4>Advanced Sharing</h4>
<p style="color: #666; font-size: 0.9em; margin-bottom: 1rem;">Configure permissions, passwords, expiration, and more</p>
<button class="btn-primary" id="advanced-share-btn">Create Advanced Share</button>
</div>
<div id="share-loading">Generating share link...</div>
<div class="modal-actions">
<div class="modal-actions" style="margin-top: 2rem;">
<button type="button" class="btn-outline" onclick="closeModal('share-modal')">Close</button>
</div>
</div>
</div>
<style>
.share-options {
margin: 1.5rem 0;
}
.share-options h4 {
margin-bottom: 0.5rem;
color: #333;
}
#quick-share-result input {
margin-bottom: 0.5rem;
}
</style>
<div id="delete-modal" class="modal">
<div class="modal-content">
<span class="close" onclick="closeModal('delete-modal')">&times;</span>
@@ -146,7 +178,7 @@
<p id="delete-message"></p>
<form id="delete-form" method="post">
<div class="modal-actions">
<button type="submit" class="btn-danger">Delete</button>
<button type="submit" class="btn-danger">🗑️</button>
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
</div>
</form>
+159
View File
@@ -0,0 +1,159 @@
{% extends "layouts/dashboard.html" %}
{% block title %}{{ file_path.split('/')[-1] }} - Editor - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.css">
<style>
.editor-container {
height: calc(100vh - 200px);
display: flex;
flex-direction: column;
}
.editor-toolbar {
background: #f5f5f5;
padding: 10px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.editor-toolbar .file-info {
font-weight: bold;
}
.editor-toolbar .actions {
display: flex;
gap: 10px;
}
.CodeMirror {
flex: 1;
border: 1px solid #ddd;
font-size: 14px;
background: white !important;
}
.save-status {
margin-left: 10px;
font-size: 12px;
color: #666;
}
</style>
{% endblock %}
{% block page_title %}Editing: {{ file_path }}{% endblock %}
{% block dashboard_actions %}
<a href="/files?path={{ file_path.rsplit('/', 1)[0] }}" class="btn-outline">Back to Files</a>
{% endblock %}
{% block dashboard_content %}
<div class="editor-container">
<div class="editor-toolbar">
<div class="file-info">
{{ file_path }}
</div>
<div class="actions">
<button id="save-btn" class="btn-primary">Save</button>
<span id="save-status" class="save-status"></span>
</div>
</div>
<textarea id="editor-textarea"></textarea>
</div>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/lib/codemirror.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/javascript/javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/python/python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/htmlmixed/htmlmixed.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/css/css.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/markdown/markdown.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/xml/xml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/shell/shell.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/mode/yaml/yaml.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/closebrackets.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/codemirror@5.65.16/addon/edit/matchbrackets.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const textarea = document.getElementById('editor-textarea');
const saveBtn = document.getElementById('save-btn');
const saveStatus = document.getElementById('save-status');
const filePath = '{{ file_path }}';
// Determine mode based on file extension
const fileName = filePath.split('/').pop();
const ext = fileName.split('.').pop().toLowerCase();
let mode = null;
if (['js', 'mjs', 'json'].includes(ext)) mode = 'javascript';
else if (ext === 'py') mode = 'python';
else if (['html', 'htm'].includes(ext)) mode = 'htmlmixed';
else if (ext === 'css') mode = 'css';
else if (['md', 'markdown'].includes(ext)) mode = 'markdown';
else if (['xml', 'svg'].includes(ext)) mode = 'xml';
else if (['sh', 'bash'].includes(ext)) mode = 'shell';
else if (['yml', 'yaml'].includes(ext)) mode = 'yaml';
// Initialize CodeMirror
const editor = CodeMirror.fromTextArea(textarea, {
lineNumbers: true,
mode: mode,
theme: 'default',
indentUnit: 4,
tabSize: 4,
indentWithTabs: false,
autoCloseBrackets: true,
matchBrackets: true,
lineWrapping: true
});
// Load file content
try {
const response = await fetch(`/api/file/content?path=${encodeURIComponent(filePath)}`);
const data = await response.json();
if (data.status === 'success') {
editor.setValue(data.content);
} else {
alert('Error loading file: ' + data.message);
}
} catch (error) {
alert('Error loading file: ' + error.message);
}
// Save functionality
saveBtn.addEventListener('click', async () => {
const content = editor.getValue();
saveStatus.textContent = 'Saving...';
saveBtn.disabled = true;
try {
const response = await fetch('/api/file/save', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
path: filePath,
content: content
})
});
const data = await response.json();
if (data.status === 'success') {
saveStatus.textContent = 'Saved successfully';
setTimeout(() => saveStatus.textContent = '', 2000);
} else {
saveStatus.textContent = 'Save failed: ' + data.message;
}
} catch (error) {
saveStatus.textContent = 'Save failed: ' + error.message;
} finally {
saveBtn.disabled = false;
}
});
// Auto-save on Ctrl+S
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveBtn.click();
}
});
});
</script>
{% endblock %}
+67
View File
@@ -0,0 +1,67 @@
{% extends "layouts/base.html" %}
{% block title %}Impressum{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>Impressum</h1>
<p>Information in accordance with Dutch law requirements</p>
<h2>Company Information</h2>
<table>
<tbody>
<tr>
<td><strong>Company Name:</strong></td>
<td>Retoor's Cloud Solutions</td>
</tr>
<tr>
<td><strong>Legal Form:</strong></td>
<td>Sole Proprietorship</td>
</tr>
<tr>
<td><strong>KvK Number:</strong></td>
<td>[To be filled in]</td>
</tr>
<tr>
<td><strong>BTW/VAT Number:</strong></td>
<td>[To be filled in]</td>
</tr>
</tbody>
</table>
<h2>Contact Information</h2>
<table>
<tbody>
<tr>
<td><strong>Business Address:</strong></td>
<td>[Street Address]<br>[Postal Code] [City]<br>The Netherlands</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>contact@retoors.nl</td>
</tr>
<tr>
<td><strong>Phone:</strong></td>
<td>[Phone Number]</td>
</tr>
</tbody>
</table>
<h2>Dispute Resolution</h2>
<p>The European Commission provides a platform for online dispute resolution (ODR): <a href="https://ec.europa.eu/consumers/odr" target="_blank">https://ec.europa.eu/consumers/odr</a></p>
<p>We are not willing or obliged to participate in dispute resolution proceedings before a consumer arbitration board.</p>
<h2>Liability for Content</h2>
<p>As service providers, we are liable for own contents of these websites according to general laws. However, we are not obliged to monitor external information provided or stored on our website. Once we have become aware of a specific infringement of the law, we will immediately remove the content in question.</p>
<h2>Liability for Links</h2>
<p>Our website contains links to external websites, over whose contents we have no control. Therefore, we cannot accept any liability for these external contents. The respective provider or operator of the websites is always responsible for the contents of the linked websites.</p>
<h2>Copyright</h2>
<p>The contents and works on these pages created by the site operators are subject to Dutch copyright law. The duplication, processing, distribution and any kind of utilization outside the limits of copyright law require the written consent of the respective author or creator.</p>
</section>
</main>
{% endblock %}
+36 -17
View File
@@ -1,6 +1,6 @@
{% extends "layouts/base.html" %}
{% block title %}Solutions for Everyone - Retoor's Cloud Solutions{% endblock %}
{% block title %}Secure Cloud Storage - Retoor's Cloud Solutions{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/index.css">
@@ -9,26 +9,45 @@
{% block content %}
<main>
<section class="hero-section">
<h1>Solutions for Everyone</h1>
<p>Solutions for Everyone</p>
<div class="benefits-grid">
<div class="benefit-card family-card">
<img src="/static/images/icon-families.svg" alt="Families Icon" class="icon">
<h3>For Families</h3>
<p>Securely backup and share precious photos and videos. Keep fond memories safe for generations.</p>
<div class="hero-content">
<h1>Secure Cloud Storage</h1>
<p class="hero-subtitle">Store, sync, and share your files with enterprise-grade security.</p>
</div>
</section>
<section class="pricing-calculator">
<div class="calculator-content">
<h2>Calculate Your Price</h2>
<p class="calculator-subtitle">Adjust the slider to find the perfect storage plan</p>
<div class="storage-display">
<span class="storage-value" id="storageValue">100</span>
<span class="storage-unit">GB</span>
</div>
<div class="benefit-card professional-card">
<img src="/static/images/icon-professionals.svg" alt="Professionals Icon" class="icon">
<h3>For Professionals</h3>
<p>Organize important work documents, collaborate in teams, and access files from anywhere.</p>
<div class="slider-container">
<input type="range" id="storageSlider" min="1" max="2000" value="100" step="1">
<div class="slider-labels">
<span>1 GB</span>
<span>2 TB</span>
</div>
</div>
<div class="benefit-card student-card">
<img src="/static/images/icon-students.svg" alt="Students Icon" class="icon">
<h3>For Students</h3>
<p>Store projects, notes, research papers. Access study materials across your devices.</p>
<div class="price-display">
<div class="price-amount">
<span class="currency">$</span>
<span class="price-value" id="priceValue">9</span>
<span class="price-period">/month</span>
</div>
<p class="price-description" id="planDescription">Personal Plan</p>
</div>
<a href="/register" class="btn-primary cta-btn">Get Started</a>
</div>
<a href="/solutions" class="btn-primary find-plan-btn">Find Your Perfect Plan</a>
</section>
</main>
{% endblock %}
{% block scripts %}
<script type="module" src="/static/js/components/pricing.js"></script>
{% endblock %}
+381
View File
@@ -0,0 +1,381 @@
{% extends "layouts/dashboard.html" %}
{% block title %}Manage Shares - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<style>
.shares-container {
padding: 1.5rem;
max-width: 1400px;
margin: 0 auto;
}
.shares-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
.share-card {
background: white;
border-radius: 8px;
padding: 1.5rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
border-left: 4px solid #0066cc;
transition: transform 0.2s, box-shadow 0.2s;
}
.share-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
.share-card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.share-item-path {
font-weight: 600;
font-size: 1.1rem;
color: #333;
word-break: break-all;
flex: 1;
margin-right: 1rem;
}
.share-status {
padding: 0.4rem 0.8rem;
border-radius: 16px;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.share-status.active {
background: #d4edda;
color: #155724;
}
.share-status.inactive {
background: #f8d7da;
color: #721c24;
}
.share-status.expired {
background: #fff3cd;
color: #856404;
}
.share-card-body {
margin-bottom: 1rem;
}
.share-info-row {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
font-size: 0.9rem;
}
.share-info-label {
color: #666;
font-weight: 500;
}
.share-info-value {
color: #333;
font-weight: 600;
}
.share-link {
font-family: monospace;
font-size: 0.85rem;
color: #0066cc;
background: #f0f7ff;
padding: 0.5rem;
border-radius: 4px;
margin: 0.75rem 0;
display: block;
word-break: break-all;
}
.share-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
.share-actions button {
flex: 1;
min-width: 80px;
padding: 0.6rem 0.8rem;
font-size: 0.85rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.empty-state-icon {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.empty-state h3 {
margin-bottom: 0.5rem;
color: #333;
}
.empty-state p {
color: #666;
margin-bottom: 1.5rem;
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
}
.recipient-list {
margin-top: 1rem;
}
.recipient-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f5f5f5;
margin-bottom: 0.5rem;
border-radius: 4px;
}
@media (max-width: 768px) {
.shares-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block page_title %}Manage Shares{% endblock %}
{% block dashboard_content %}
<div class="shares-container">
{% if shares %}
<div class="shares-grid" id="shares-list">
{% for share in shares %}
<div class="share-card" data-share-id="{{ share.share_id }}">
<div class="share-card-header">
<div class="share-item-path">{{ share.item_path }}</div>
{% if not share.active %}
<span class="share-status inactive">Inactive</span>
{% elif share.expires_at and share.expires_at < now %}
<span class="share-status expired">Expired</span>
{% else %}
<span class="share-status active">Active</span>
{% endif %}
</div>
<div class="share-card-body">
<div class="share-link">/share/{{ share.share_id }}</div>
<div class="share-info-row">
<span class="share-info-label">Permission</span>
<span class="share-info-value">{{ share.permission }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Scope</span>
<span class="share-info-value">{{ share.scope }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Access Count</span>
<span class="share-info-value">{{ share.access_count or 0 }} views</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Created</span>
<span class="share-info-value">{{ share.created_at[:10] }}</span>
</div>
<div class="share-info-row">
<span class="share-info-label">Expires</span>
<span class="share-info-value">{{ share.expires_at[:10] if share.expires_at else 'Never' }}</span>
</div>
</div>
<div class="share-actions">
<button class="btn-small copy-link-btn" data-share-id="{{ share.share_id }}">Copy Link</button>
<button class="btn-small view-details-btn" data-share-id="{{ share.share_id }}">Details</button>
{% if share.active %}
<button class="btn-small deactivate-btn" data-share-id="{{ share.share_id }}">Deactivate</button>
{% else %}
<button class="btn-small activate-btn" data-share-id="{{ share.share_id }}">Activate</button>
{% endif %}
<button class="btn-small delete-btn btn-danger" data-share-id="{{ share.share_id }}">Delete</button>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-state-icon">đź”—</div>
<h3>No Shares Yet</h3>
<p>You have not created any shares. Start sharing your files and folders with others.</p>
<a href="/files" class="btn-primary">Go to My Files</a>
</div>
{% endif %}
</div>
<div class="modal" id="share-details-modal">
<div class="modal-content">
<div class="modal-header">
<h2>Share Details</h2>
<button class="modal-close">&times;</button>
</div>
<div id="share-details-content">
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="module">
const sharesList = document.getElementById('shares-list');
const detailsModal = document.getElementById('share-details-modal');
const detailsContent = document.getElementById('share-details-content');
const modalClose = document.querySelector('.modal-close');
if (sharesList) {
sharesList.addEventListener('click', async (e) => {
const shareId = e.target.dataset.shareId;
if (!shareId) return;
if (e.target.classList.contains('view-details-btn')) {
await viewShareDetails(shareId);
} else if (e.target.classList.contains('copy-link-btn')) {
copyShareLink(shareId);
} else if (e.target.classList.contains('deactivate-btn')) {
await updateShare(shareId, 'deactivate');
} else if (e.target.classList.contains('activate-btn')) {
await updateShare(shareId, 'reactivate');
} else if (e.target.classList.contains('delete-btn')) {
if (confirm('Are you sure you want to delete this share?')) {
await updateShare(shareId, 'delete');
}
}
});
}
async function viewShareDetails(shareId) {
try {
const response = await fetch(`/api/sharing/${shareId}`);
const data = await response.json();
let html = `
<div class="form-group">
<label>Item Path</label>
<p>${data.share.item_path}</p>
</div>
<div class="form-group">
<label>Permission</label>
<p>${data.share.permission}</p>
</div>
<div class="form-group">
<label>Scope</label>
<p>${data.share.scope}</p>
</div>
`;
if (data.recipients && data.recipients.length > 0) {
html += `
<div class="form-group">
<label>Recipients</label>
<div class="recipient-list">
${data.recipients.map(r => `
<div class="recipient-item">
<div>
<div>${r.email}</div>
<small>Permission: ${r.permission}</small>
</div>
<div>
${r.accessed ? 'Accessed' : 'Not accessed'}
</div>
</div>
`).join('')}
</div>
</div>
`;
}
detailsContent.innerHTML = html;
detailsModal.classList.add('active');
} catch (error) {
alert('Error loading share details: ' + error.message);
}
}
function copyShareLink(shareId) {
const url = window.location.origin + '/share/' + shareId;
navigator.clipboard.writeText(url).then(() => {
alert('Share link copied to clipboard!');
});
}
async function updateShare(shareId, action) {
try {
const response = await fetch(`/api/sharing/${shareId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ action })
});
const result = await response.json();
if (result.success) {
location.reload();
} else {
alert('Error: ' + result.error);
}
} catch (error) {
alert('Error updating share: ' + error.message);
}
}
modalClose.addEventListener('click', () => {
detailsModal.classList.remove('active');
});
detailsModal.addEventListener('click', (e) => {
if (e.target === detailsModal) {
detailsModal.classList.remove('active');
}
});
</script>
{% endblock %}
+135
View File
@@ -0,0 +1,135 @@
{% extends "layouts/dashboard.html" %}
{% block title %}{{ file_path.split('/')[-1] }} - Viewer - Retoor's Cloud Solutions{% endblock %}
{% block dashboard_head %}
<style>
.viewer-container {
height: calc(100vh - 200px);
display: flex;
flex-direction: column;
}
.viewer-toolbar {
background: #f5f5f5;
padding: 10px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.viewer-toolbar .file-info {
font-weight: bold;
}
.viewer-toolbar .actions {
display: flex;
gap: 10px;
}
.media-display {
flex: 1;
display: flex;
justify-content: center;
align-items: center;
background: #f0f0f0;
border: 1px solid #ddd;
}
.media-display img, .media-display video, .media-display audio {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
</style>
{% endblock %}
{% block page_title %}Viewing: {{ file_path }}{% endblock %}
{% block dashboard_actions %}
<a href="/files?path={{ file_path.rsplit('/', 1)[0] }}" class="btn-outline">Back to Files</a>
{% endblock %}
{% block dashboard_content %}
<div class="viewer-container">
<div class="viewer-toolbar">
<div class="file-info">
{{ file_path }}
</div>
<div class="actions">
<!-- Add any actions if needed -->
</div>
</div>
<div class="media-display" id="media-display">
<p>Loading...</p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const displayDiv = document.getElementById('media-display');
const filePath = '{{ file_path }}';
// Determine type based on file extension
const fileName = filePath.split('/').pop();
const ext = fileName.split('.').pop().toLowerCase();
const isImage = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg'].includes(ext);
const isVideo = ['mp4', 'webm', 'ogg', 'avi', 'mov'].includes(ext);
const isAudio = ['mp3', 'wav', 'ogg', 'aac'].includes(ext);
let mimeType = 'application/octet-stream';
if (isImage) {
const mimeMap = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', bmp: 'image/bmp', webp: 'image/webp', svg: 'image/svg+xml' };
mimeType = mimeMap[ext] || 'image/jpeg';
} else if (isVideo) {
const mimeMap = { mp4: 'video/mp4', webm: 'video/webm', ogg: 'video/ogg', avi: 'video/avi', mov: 'video/quicktime' };
mimeType = mimeMap[ext] || 'video/mp4';
} else if (isAudio) {
const mimeMap = { mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac' };
mimeType = mimeMap[ext] || 'audio/mpeg';
}
if (!isImage && !isVideo && !isAudio) {
displayDiv.innerHTML = '<p>Unsupported file type for viewing.</p>';
return;
}
try {
const response = await fetch(`/api/file/content?path=${encodeURIComponent(filePath)}&binary=true`);
const data = await response.json();
if (data.status === 'success') {
// Assume content is base64 encoded for binary files
const binaryString = atob(data.content);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
const url = URL.createObjectURL(blob);
if (isImage) {
const img = document.createElement('img');
img.src = url;
img.onload = () => URL.revokeObjectURL(url);
displayDiv.innerHTML = '';
displayDiv.appendChild(img);
} else if (isVideo) {
const video = document.createElement('video');
video.src = url;
video.controls = true;
video.onload = () => URL.revokeObjectURL(url);
displayDiv.innerHTML = '';
displayDiv.appendChild(video);
} else if (isAudio) {
const audio = document.createElement('audio');
audio.src = url;
audio.controls = true;
audio.onload = () => URL.revokeObjectURL(url);
displayDiv.innerHTML = '';
displayDiv.appendChild(audio);
}
} else {
displayDiv.innerHTML = '<p>Error loading file: ' + data.message + '</p>';
}
} catch (error) {
displayDiv.innerHTML = '<p>Error loading file: ' + error.message + '</p>';
}
});
</script>
{% endblock %}
-81
View File
@@ -1,81 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}Pricing - Retoor's Cloud Solutions{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/pricing.css">
{% endblock %}
{% block content %}
<main>
<section class="pricing-hero">
<h1>Simple, Transparent Pricing</h1>
<p>Find the perfect plan for your needs.</p>
<div class="pricing-toggle">
<button class="btn-toggle active" data-period="monthly">Monthly</button>
<button class="btn-toggle" data-period="annually">Annually</button>
</div>
</section>
<section class="pricing-tiers">
<div class="pricing-card">
<h3>Free</h3>
<p class="price">$0<span>/month</span></p>
<ul>
<li>1 GB Storage</li>
<li>Basic Sync & Share</li>
<li>Standard Support</li>
</ul>
<a href="/register" class="btn-primary">Get Started</a>
</div>
<div class="pricing-card featured">
<h3>Personal</h3>
<p class="price">$9<span>/month</span></p>
<ul>
<li>100 GB Storage</li>
<li>Advanced Sync & Share</li>
<li>Priority Support</li>
<li>Version History</li>
</ul>
<a href="/register" class="btn-primary">Sign Up Now</a>
</div>
<div class="pricing-card">
<h3>Professional</h3>
<p class="price">$29<span>/month</span></p>
<ul>
<li>1 TB Storage</li>
<li>Team Collaboration</li>
<li>24/7 Premium Support</li>
<li>Advanced Security</li>
</ul>
<a href="/register" class="btn-primary">Sign Up Now</a>
</div>
<div class="pricing-card">
<h3>Business</h3>
<p class="price">$99<span>/month</span></p>
<ul>
<li>Unlimited Storage</li>
<li>Custom Solutions</li>
<li>Dedicated Account Manager</li>
<li>Advanced Analytics</li>
</ul>
<a href="/support" class="btn-primary">Contact Sales</a>
</div>
</section>
<section class="pricing-faq">
<h2>Frequently Asked Questions</h2>
<div class="faq-item">
<h3>Can I change my plan later?</h3>
<p>Yes, you can upgrade or downgrade your plan at any time from your dashboard.</p>
</div>
<div class="faq-item">
<h3>What payment methods do you accept?</h3>
<p>We accept all major credit cards, PayPal, and bank transfers for annual plans.</p>
</div>
</section>
</main>
{% endblock %}
+9 -3
View File
@@ -52,8 +52,14 @@
<img src="/static/images/icon-families.svg" alt="Folder Icon" class="file-icon">
<a href="/files?path={{ item.path }}">{{ item.name }}</a>
{% else %}
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
{{ item.name }}
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
{% if item.is_editable %}
<a href="/editor?path={{ item.path }}">{{ item.name }}</a>
{% elif item.is_viewable %}
<a href="/viewer?path={{item.path}}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
{% endif %}
</td>
<td>{{ user.email }}</td>
@@ -119,4 +125,4 @@
</div>
<script type="module" src="/static/js/main.js"></script>
{% endblock %}
{% endblock %}
-62
View File
@@ -1,62 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}Security - Retoor's Cloud Solutions{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/security.css">
{% endblock %}
{% block content %}
<main>
<section class="security-hero">
<h1>Your Data, Our Priority. Uncompromising Security.</h1>
<p>At Retoor's Cloud Solutions, we understand the critical importance of data security and privacy. We employ industry-leading measures to ensure your information is always protected.</p>
</section>
<section class="security-pillars">
<div class="pillar-card">
<img src="/static/images/icon-professionals.svg" alt="Encryption Icon" class="icon">
<h3>Robust Encryption</h3>
<p>All your data is encrypted both in transit (TLS 1.2+) and at rest (AES-256), ensuring maximum confidentiality and integrity.</p>
</div>
<div class="pillar-card">
<img src="/static/images/icon-professionals.svg" alt="Access Control Icon" class="icon">
<h3>Advanced Access Control</h3>
<p>Implement granular permissions, multi-factor authentication (MFA), and strict access policies to keep your data safe from unauthorized access.</p>
</div>
<div class="pillar-card">
<img src="/static/images/icon-families.svg" alt="Privacy Icon" class="icon">
<h3>Unwavering Privacy</h3>
<p>We are committed to your privacy. Our policies are transparent, and we comply with global data protection regulations like GDPR and CCPA.</p>
</div>
<div class="pillar-card">
<img src="/static/images/icon-professionals.svg" alt="Infrastructure Icon" class="icon">
<h3>Secure Infrastructure</h3>
<p>Our data centers are physically secured, and our network is protected by advanced firewalls and intrusion detection systems, regularly audited for vulnerabilities.</p>
</div>
</section>
<section class="security-certifications">
<h2>Trust & Compliance</h2>
<div class="cert-grid">
<div class="cert-item">
<img src="/static/images/icon-professionals.svg" alt="ISO 27001 Certified" class="cert-logo">
<p>ISO 27001 Certified</p>
</div>
<div class="cert-item">
<img src="/static/images/icon-professionals.svg" alt="SOC 2 Compliant" class="cert-logo">
<p>SOC 2 Compliant</p>
</div>
<div class="cert-item">
<img src="/static/images/icon-families.svg" alt="GDPR Compliant" class="cert-logo">
<p>GDPR Compliant</p>
</div>
</div>
</section>
<section class="security-cta">
<h2>Have More Questions About Security?</h2>
<a href="/support" class="btn-primary">Contact Support</a>
</section>
</main>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "layouts/base.html" %}
{% block title %}Share Error{% endblock %}
{% block head %}
<style>
.error-container {
max-width: 600px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
}
.error-icon {
font-size: 4rem;
margin-bottom: 1rem;
}
.error-message {
color: #666;
margin: 1.5rem 0;
}
</style>
{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">đź”’</div>
<h1>Access Denied</h1>
<p class="error-message">{{ error }}</p>
<a href="/" class="btn-primary">Go to Homepage</a>
</div>
{% endblock %}
+81
View File
@@ -0,0 +1,81 @@
{% extends "layouts/base.html" %}
{% block title %}Shared File - {{ file_name }}{% endblock %}
{% block head %}
<style>
.share-container {
max-width: 800px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.share-header {
border-bottom: 1px solid #eee;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.file-icon-large {
width: 64px;
height: 64px;
margin: 0 auto 1rem;
display: block;
}
.file-info {
display: grid;
grid-template-columns: 150px 1fr;
gap: 1rem;
margin: 1.5rem 0;
}
.file-info dt {
font-weight: 600;
}
.file-actions {
display: flex;
gap: 1rem;
margin-top: 2rem;
}
.permission-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 12px;
font-size: 0.875rem;
}
</style>
{% endblock %}
{% block content %}
<div class="share-container">
<div class="share-header">
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon-large">
<h1>{{ file_name }}</h1>
<span class="permission-badge">{{ permission }}</span>
</div>
<dl class="file-info">
<dt>File Name</dt>
<dd>{{ file_name }}</dd>
<dt>Size</dt>
<dd>{{ (file_size / 1024 / 1024)|round(2) }} MB</dd>
<dt>Path</dt>
<dd>{{ item_path }}</dd>
<dt>Permission</dt>
<dd>{{ permission }}</dd>
</dl>
<div class="file-actions">
{% if not disable_download %}
<a href="/share/{{ share_id }}/download" class="btn-primary" download>Download File</a>
{% else %}
<button class="btn-outline" disabled>Download Disabled</button>
{% endif %}
</div>
</div>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% extends "layouts/base.html" %}
{% block title %}Shared Folder - {{ item_path }}{% endblock %}
{% block head %}
<style>
.share-container {
max-width: 1200px;
margin: 4rem auto;
padding: 2rem;
background: white;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.share-header {
border-bottom: 1px solid #eee;
padding-bottom: 1.5rem;
margin-bottom: 1.5rem;
}
.permission-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
background: #e3f2fd;
color: #1976d2;
border-radius: 12px;
font-size: 0.875rem;
margin-left: 1rem;
}
.file-list-table {
width: 100%;
}
.file-list-table table {
width: 100%;
border-collapse: collapse;
}
.file-list-table th {
background: #f5f5f5;
padding: 1rem;
text-align: left;
font-weight: 600;
}
.file-list-table td {
padding: 1rem;
border-top: 1px solid #eee;
}
.file-icon {
width: 20px;
height: 20px;
margin-right: 0.5rem;
vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="share-container">
<div class="share-header">
<h1>
{{ item_path.split('/')[-1] if item_path else 'Shared Folder' }}
<span class="permission-badge">{{ permission }}</span>
</h1>
<p>{{ item_path }}</p>
</div>
<div class="file-list-table">
<table>
<thead>
<tr>
<th>Name</th>
<th>Last Modified</th>
<th>Size</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files %}
{% for item in files %}
<tr>
<td>
{% if item.is_dir %}
<img src="/static/images/icon-families.svg" alt="Folder Icon" class="file-icon">
{{ item.name }}
{% else %}
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
{{ item.name }}
{% endif %}
</td>
<td>{{ item.last_modified[:10] if item.last_modified else '' }}</td>
<td>
{% if item.is_dir %}
--
{% else %}
{{ (item.size / 1024 / 1024)|round(2) }} MB
{% endif %}
</td>
<td>
{% if not item.is_dir and not disable_download %}
<a href="/share/{{ share_id }}/download?file_path={{ item.path }}" class="btn-small">Download</a>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" style="text-align: center; padding: 2rem; color: #999;">
This folder is empty
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+3 -3
View File
@@ -13,7 +13,7 @@
<button class="btn-outline" id="upload-btn">Upload</button>
<button class="btn-outline" id="download-selected-btn" disabled>⬇️</button>
<button class="btn-outline" id="share-selected-btn" disabled>đź”—</button>
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</button>
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
{% endblock %}
{% block dashboard_content %}
@@ -73,7 +73,7 @@
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
{% endif %}
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
</div>
</td>
</tr>
@@ -144,7 +144,7 @@
<p id="delete-message"></p>
<form id="delete-form" method="post">
<div class="modal-actions">
<button type="submit" class="btn-danger">Delete</button>
<button type="submit" class="btn-danger">🗑️</button>
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
</div>
</form>
+219
View File
@@ -0,0 +1,219 @@
{% extends "layouts/base.html" %}
{% block title %}Service Level Agreement{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>Service Level Agreement</h1>
<p>Last updated: January 2025</p>
<p>This Service Level Agreement applies to Business and Enterprise plan customers of Retoor's Cloud Solutions.</p>
<h2>1. Service Availability</h2>
<h3>1.1 Uptime Guarantee</h3>
<table>
<thead>
<tr>
<th>Service Tier</th>
<th>Monthly Uptime Guarantee</th>
<th>Maximum Downtime per Month</th>
</tr>
</thead>
<tbody>
<tr>
<td>Personal</td>
<td>99.0%</td>
<td>7.2 hours</td>
</tr>
<tr>
<td>Professional</td>
<td>99.5%</td>
<td>3.6 hours</td>
</tr>
<tr>
<td>Business</td>
<td>99.9%</td>
<td>43.2 minutes</td>
</tr>
<tr>
<td>Enterprise</td>
<td>99.95%</td>
<td>21.6 minutes</td>
</tr>
</tbody>
</table>
<h3>1.2 Planned Maintenance</h3>
<p>Planned maintenance windows do not count against uptime guarantees. We will:</p>
<ul>
<li>Provide at least 48 hours notice for planned maintenance</li>
<li>Schedule maintenance during off-peak hours when possible</li>
<li>Limit planned maintenance to 4 hours per month for Business plans</li>
<li>Limit planned maintenance to 2 hours per month for Enterprise plans</li>
</ul>
<h2>2. Support Response Times</h2>
<h3>2.1 Support Channels</h3>
<table>
<thead>
<tr>
<th>Service Tier</th>
<th>Support Channels</th>
<th>Support Hours</th>
</tr>
</thead>
<tbody>
<tr>
<td>Personal</td>
<td>Email only</td>
<td>Business hours (9-17 CET)</td>
</tr>
<tr>
<td>Professional</td>
<td>Email, Chat</td>
<td>Extended hours (8-20 CET)</td>
</tr>
<tr>
<td>Business</td>
<td>Email, Chat, Phone</td>
<td>24/7</td>
</tr>
<tr>
<td>Enterprise</td>
<td>Email, Chat, Phone, Dedicated Account Manager</td>
<td>24/7</td>
</tr>
</tbody>
</table>
<h3>2.2 Response Time Commitments</h3>
<table>
<thead>
<tr>
<th>Priority Level</th>
<th>Description</th>
<th>Business Plan</th>
<th>Enterprise Plan</th>
</tr>
</thead>
<tbody>
<tr>
<td>Critical (P1)</td>
<td>Service completely unavailable</td>
<td>1 hour</td>
<td>30 minutes</td>
</tr>
<tr>
<td>High (P2)</td>
<td>Major functionality impaired</td>
<td>4 hours</td>
<td>2 hours</td>
</tr>
<tr>
<td>Medium (P3)</td>
<td>Minor functionality issues</td>
<td>1 business day</td>
<td>8 hours</td>
</tr>
<tr>
<td>Low (P4)</td>
<td>General questions, feature requests</td>
<td>2 business days</td>
<td>1 business day</td>
</tr>
</tbody>
</table>
<h2>3. Data Backup Guarantees</h2>
<ul>
<li><strong>Backup Frequency:</strong> Daily automated backups for all paid plans</li>
<li><strong>Retention Period:</strong> 30 days for Business plans, 90 days for Enterprise plans</li>
<li><strong>Recovery Point Objective (RPO):</strong> 24 hours maximum data loss</li>
<li><strong>Recovery Time Objective (RTO):</strong> 4 hours for Business, 2 hours for Enterprise</li>
<li><strong>Data Redundancy:</strong> All data stored with triple redundancy across multiple data centers</li>
</ul>
<h2>4. Performance Standards</h2>
<ul>
<li><strong>File Upload Speed:</strong> Minimum 10 Mbps under normal conditions</li>
<li><strong>File Download Speed:</strong> Minimum 25 Mbps under normal conditions</li>
<li><strong>API Response Time:</strong> 95% of requests completed within 500ms</li>
<li><strong>File Access Latency:</strong> Maximum 100ms for file metadata operations</li>
</ul>
<h2>5. Service Credits</h2>
<h3>5.1 Credit Calculation</h3>
<p>If we fail to meet the uptime guarantee, you are eligible for service credits:</p>
<table>
<thead>
<tr>
<th>Monthly Uptime Percentage</th>
<th>Service Credit</th>
</tr>
</thead>
<tbody>
<tr>
<td>99.0% - 99.5%</td>
<td>10% of monthly fee</td>
</tr>
<tr>
<td>95.0% - 99.0%</td>
<td>25% of monthly fee</td>
</tr>
<tr>
<td>90.0% - 95.0%</td>
<td>50% of monthly fee</td>
</tr>
<tr>
<td>Below 90.0%</td>
<td>100% of monthly fee</td>
</tr>
</tbody>
</table>
<h3>5.2 Claiming Credits</h3>
<p>To claim service credits:</p>
<ul>
<li>Submit a claim within 30 days of the incident</li>
<li>Provide details of the downtime experienced</li>
<li>Credits will be applied to your next monthly invoice</li>
<li>Credits cannot be exchanged for cash</li>
</ul>
<h2>6. Exclusions</h2>
<p>This SLA does not apply to service unavailability caused by:</p>
<ul>
<li>Factors outside our reasonable control (force majeure)</li>
<li>Your equipment, software, or internet connection</li>
<li>Violation of our Acceptable Use Policy</li>
<li>Scheduled maintenance with proper notice</li>
<li>Suspension or termination of your account for breach of terms</li>
</ul>
<h2>7. Monitoring and Reporting</h2>
<p>We provide:</p>
<ul>
<li>Real-time service status dashboard at status.retoors.nl</li>
<li>Monthly uptime reports for Business and Enterprise customers</li>
<li>Email notifications for incidents affecting your service</li>
<li>Post-incident reports for P1 and P2 incidents</li>
</ul>
<h2>8. Changes to This SLA</h2>
<p>We may modify this SLA with 30 days notice. Material changes that reduce service levels will allow you to terminate your contract without penalty.</p>
<h2>9. Contact</h2>
<p>For SLA-related questions or to report service issues:</p>
<ul>
<li>Email: sla@retoors.nl</li>
<li>Phone (Business/Enterprise): [Business Support Number]</li>
<li>Status Page: status.retoors.nl</li>
</ul>
</section>
</main>
{% endblock %}
-75
View File
@@ -1,75 +0,0 @@
{% extends "layouts/base.html" %}
{% block title %}Solutions - Retoor's Cloud Solutions{% endblock %}
{% block head %}
<link rel="stylesheet" href="/static/css/components/solutions.css">
{% endblock %}
{% block content %}
<main>
<section class="solutions-hero">
<h1>Powerful Cloud Solutions for Modern Needs</h1>
<p>Discover how Retoor's Cloud Solutions can empower your family, business, or academic pursuits with secure, accessible, and collaborative tools.</p>
</section>
<section class="solution-sections">
<div class="solution-card">
<img src="/static/images/icon-families.svg" alt="Family Storage Icon" class="icon">
<h2>Secure Family Storage</h2>
<p>Keep your family's precious memories safe and accessible. Securely store photos, videos, and important documents, and easily share them with loved ones.</p>
<ul>
<li>Private photo and video galleries</li>
<li>Shared family albums</li>
<li>Secure document vault for wills, deeds, etc.</li>
<li>Easy sharing with granular permissions</li>
</ul>
<a href="/pricing" class="btn-primary">See Family Plans</a>
</div>
<div class="solution-card">
<img src="/static/images/icon-professionals.svg" alt="Business Collaboration Icon" class="icon">
<h2>Business Collaboration & Productivity</h2>
<p>Boost your team's efficiency with seamless collaboration tools. Share files, co-edit documents, and manage projects from anywhere, securely.</p>
<ul>
<li>Real-time document co-editing</li>
<li>Secure file sharing with external partners</li>
<li>Version control and recovery</li>
<li>Team workspaces and project folders</li>
</ul>
<a href="/pricing" class="btn-primary">Explore Business Solutions</a>
</div>
<div class="solution-card">
<img src="/static/images/icon-students.svg" alt="Academic Storage Icon" class="icon">
<h2>Academic & Student Resources</h2>
<p>Organize your academic life with dedicated storage for projects, research, and notes. Access your study materials across all your devices.</p>
<ul>
<li>Centralized storage for assignments and research</li>
<li>Easy access from campus or home</li>
<li>Secure sharing for group projects</li>
<li>Integration with academic tools (coming soon)</li>
</ul>
<a href="/pricing" class="btn-primary">View Student Plans</a>
</div>
<div class="solution-card">
<img src="/static/images/icon-families.svg" alt="Automated Backup & Recovery Icon" class="icon">
<h2>Automated Backup & Recovery</h2>
<p>Never lose a file again. Our automated backup solutions ensure your data is always safe, with easy recovery options for any scenario.</p>
<ul>
<li>Scheduled automatic backups</li>
<li>Point-in-time recovery</li>
<li>Disaster recovery planning</li>
<li>Secure, off-site data replication</li>
</ul>
<a href="/pricing" class="btn-primary">Learn About Backup</a>
</div>
</section>
<section class="solutions-cta">
<h2>Ready to Explore Our Plans?</h2>
<a href="/pricing" class="btn-primary">View All Pricing</a>
</section>
</main>
{% endblock %}
+3 -3
View File
@@ -10,7 +10,7 @@
{% block dashboard_actions %}
<button class="btn-outline" id="restore-selected-btn" disabled>Restore</button>
<button class="btn-outline" id="delete-selected-btn" disabled>Delete Permanently</button>
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
{% endblock %}
{% block dashboard_content %}
@@ -67,7 +67,7 @@
<td>
<div class="action-buttons">
<button class="btn-small restore-file-btn" data-path="{{ item.path }}">Restore</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete Permanently</button>
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
</div>
</td>
</tr>
@@ -104,7 +104,7 @@
<p id="delete-message"></p>
<form id="delete-form" method="post">
<div class="modal-actions">
<button type="submit" class="btn-danger">Delete Permanently</button>
<button type="submit" class="btn-danger">🗑️</button>
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
</div>
</form>
+1 -1
View File
@@ -44,7 +44,7 @@
<div style="display: flex; gap: 10px; margin-top: 30px;">
<a href="/users/{{ user_data.email }}/edit" class="btn-primary">Edit Quota</a>
<form action="/users/{{ user_data.email }}/delete" method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this user?');">
<button type="submit" class="btn-danger">Delete User</button>
<button type="submit" class="btn-danger">🗑️</button>
</form>
<a href="/users" class="btn-outline">Back to Users</a>
</div>
+88
View File
@@ -0,0 +1,88 @@
{% extends "layouts/base.html" %}
{% block title %}User Rights Request{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="/static/css/components/content_pages.css">
{% endblock %}
{% block content %}
<main>
<section class="content-section">
<h1>User Rights Request</h1>
<p>Under the General Data Protection Regulation (GDPR), you have several rights regarding your personal data. You can exercise these rights by submitting a request below.</p>
<h2>Your Rights Under GDPR</h2>
<ul>
<li><strong>Right to Access:</strong> You can request a copy of all personal data we hold about you.</li>
<li><strong>Right to Rectification:</strong> You can request that we correct any inaccurate or incomplete personal data.</li>
<li><strong>Right to Erasure:</strong> You can request that we delete your personal data under certain circumstances.</li>
<li><strong>Right to Data Portability:</strong> You can request a copy of your data in a machine-readable format.</li>
<li><strong>Right to Object:</strong> You can object to certain types of processing of your personal data.</li>
<li><strong>Right to Restrict Processing:</strong> You can request that we limit the processing of your personal data.</li>
</ul>
<h2>Request Data Access</h2>
<p>Request a download of all your personal data that we store.</p>
<form action="/user_rights/access" method="post">
<div class="form-group">
<label for="email">Your Email Address</label>
<input type="email" id="email" name="email" class="form-input" required>
</div>
<div class="form-group">
<label for="message">Additional Information (Optional)</label>
<textarea id="message" name="message" class="form-input" rows="4"></textarea>
</div>
<button type="submit" class="btn-primary">Request Data Access</button>
</form>
<h2>Request Data Deletion</h2>
<p>Request the permanent deletion of your account and all associated data.</p>
<form action="/user_rights/delete" method="post">
<div class="form-group">
<label for="email_delete">Your Email Address</label>
<input type="email" id="email_delete" name="email" class="form-input" required>
</div>
<div class="form-group">
<label for="reason">Reason for Deletion (Optional)</label>
<textarea id="reason" name="reason" class="form-input" rows="4"></textarea>
</div>
<div class="form-group">
<label>
<input type="checkbox" name="confirm" required>
I understand that this action is permanent and all my data will be deleted.
</label>
</div>
<button type="submit" class="btn-danger">Request Data Deletion</button>
</form>
<h2>Request Data Correction</h2>
<p>Request correction of inaccurate or incomplete personal data.</p>
<form action="/user_rights/correct" method="post">
<div class="form-group">
<label for="email_correct">Your Email Address</label>
<input type="email" id="email_correct" name="email" class="form-input" required>
</div>
<div class="form-group">
<label for="correction_details">What needs to be corrected?</label>
<textarea id="correction_details" name="correction_details" class="form-input" rows="4" required></textarea>
</div>
<button type="submit" class="btn-primary">Request Data Correction</button>
</form>
<h2>Response Time</h2>
<p>We will respond to your request within 30 days of receipt. If we need additional time, we will notify you and provide a reason for the delay.</p>
<h2>Contact for Privacy Concerns</h2>
<p>If you have any questions about your rights or how we process your data, please contact us at: <a href="mailto:privacy@retoors.nl">privacy@retoors.nl</a></p>
<h2>File a Complaint</h2>
<p>If you believe your data protection rights have been violated, you have the right to lodge a complaint with the Dutch Data Protection Authority (Autoriteit Persoonsgegevens):</p>
<p>
<strong>Autoriteit Persoonsgegevens</strong><br>
Postbus 93374<br>
2509 AJ Den Haag<br>
The Netherlands<br>
Website: <a href="https://autoriteitpersoonsgegevens.nl" target="_blank">https://autoriteitpersoonsgegevens.nl</a>
</p>
</section>
</main>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
from aiohttp import web
import aiohttp_jinja2
from aiohttp.web_response import json_response
from ..helpers.auth import login_required
class FileEditorView(web.View):
@login_required
async def get(self):
file_path = self.request.query.get('path', '')
user = self.request.get('user')
if not file_path:
return web.Response(text="No file path specified", status=400)
return aiohttp_jinja2.render_template(
'pages/file_editor.html',
self.request,
{
'request': self.request,
'user': user,
'file_path': file_path,
'active_page': 'files'
}
)
@login_required
async def post(self):
user_email = self.request['user']['email']
file_service = self.request.app['file_service']
try:
data = await self.request.json()
file_path = data.get('path')
content = data.get('content')
if not file_path:
return json_response({'status': 'error', 'message': 'No file path specified'}, status=400)
if content is None:
return json_response({'status': 'error', 'message': 'No content provided'}, status=400)
success = await file_service.save_file_content(user_email, file_path, content)
if success:
return json_response({'status': 'success', 'message': 'File saved successfully'})
else:
return json_response({'status': 'error', 'message': 'Failed to save file'}, status=500)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, status=500)
class FileContentView(web.View):
@login_required
async def get(self):
user_email = self.request['user']['email']
file_service = self.request.app['file_service']
file_path = self.request.query.get('path', '')
binary = self.request.query.get('binary', 'false').lower() == 'true'
if not file_path:
return json_response({'status': 'error', 'message': 'No file path specified'}, status=400)
try:
if binary:
content_bytes = await file_service.read_file_content_binary(user_email, file_path)
if content_bytes is not None:
import base64
content = base64.b64encode(content_bytes).decode('utf-8')
return json_response({'status': 'success', 'content': content})
else:
return json_response({'status': 'error', 'message': 'File not found or cannot be read'}, status=404)
else:
content = await file_service.read_file_content(user_email, file_path)
if content is not None:
return json_response({'status': 'success', 'content': content})
else:
return json_response({'status': 'error', 'message': 'File not found or cannot be read'}, status=404)
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, status=500)
+381
View File
@@ -0,0 +1,381 @@
import aiohttp_jinja2
from aiohttp import web
import json
import logging
from ..helpers.email_sender import send_email
from ..helpers.auth import login_required
logger = logging.getLogger(__name__)
async def send_share_invitations(app, sender_email, recipient_emails, item_path, share_url, permission, password):
for recipient in recipient_emails:
subject = f"{sender_email} shared '{item_path}' with you"
password_note = ""
if password:
password_note = f"<p><strong>This share is password protected.</strong> You will need to enter the password to access it.</p>"
body = f"""
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2>You have been invited to access a shared item</h2>
<p><strong>{sender_email}</strong> has shared <strong>{item_path}</strong> with you.</p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p><strong>Permission Level:</strong> {permission}</p>
<p><strong>Item:</strong> {item_path}</p>
</div>
{password_note}
<p>Click the button below to access the shared item:</p>
<a href="{share_url}" style="display: inline-block; background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0;">
Access Shared Item
</a>
<p>Or copy and paste this link into your browser:</p>
<p style="background: #f5f5f5; padding: 10px; border-radius: 4px; word-break: break-all;">
{share_url}
</p>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
<p style="color: #666; font-size: 12px;">
This invitation was sent by Retoor's Cloud Solutions.<br>
If you believe you received this email in error, please disregard it.
</p>
</body>
</html>
"""
try:
await send_email(app, recipient, subject, body)
logger.info(f"Sent share invitation to {recipient} for {item_path}")
except Exception as e:
logger.error(f"Failed to send share invitation to {recipient}: {e}")
@login_required
@aiohttp_jinja2.template('pages/create_share.html')
async def create_share_page(request: web.Request):
user = request['user']
user_email = user['email']
item_path = request.query.get('item_path', '')
return {
'request': request,
'user_email': user_email,
'item_path': item_path,
'active_page': 'my_shares',
'user': user
}
@login_required
async def create_share_handler(request: web.Request):
user = request['user']
user_email = user['email']
try:
data = await request.json()
except json.JSONDecodeError:
return web.json_response({'error': 'Invalid JSON'}, status=400)
item_path = data.get('item_path')
if not item_path:
return web.json_response({'error': 'item_path is required'}, status=400)
permission = data.get('permission', 'view')
scope = data.get('scope', 'public')
password = data.get('password')
expiration_days = data.get('expiration_days')
disable_download = data.get('disable_download', False)
recipient_emails = data.get('recipient_emails', [])
file_service = request.app['file_service']
try:
share_id = await file_service.generate_share_link(
user_email=user_email,
item_path=item_path,
permission=permission,
scope=scope,
password=password,
expiration_days=expiration_days,
disable_download=disable_download,
recipient_emails=recipient_emails
)
if share_id:
share_url = str(request.url.origin()) + f'/share/{share_id}'
if recipient_emails and len(recipient_emails) > 0:
logger.info(f"Sending email invitations to {len(recipient_emails)} recipients: {recipient_emails}")
await send_share_invitations(
request.app,
user_email,
recipient_emails,
item_path,
share_url,
permission,
password
)
else:
logger.debug(f"No recipients specified for share {share_id}, skipping email invitations")
return web.json_response({
'success': True,
'share_id': share_id,
'share_url': share_url
})
else:
return web.json_response({'error': 'Failed to create share'}, status=400)
except Exception as e:
logger.error(f"Error creating share: {e}")
return web.json_response({'error': str(e)}, status=500)
async def view_share(request: web.Request):
share_id = request.match_info['share_id']
file_service = request.app['file_service']
password = request.query.get('password')
accessor_email = request.get('user', {}).get('email') if request.get('user') else None
shared_item = await file_service.get_shared_item(share_id, password, accessor_email)
share = await file_service.sharing_service.get_share(share_id)
if not share:
# Check for specific reasons why the share might not be found
# (e.g., expired, deactivated, or truly not found)
# This requires deeper inspection within sharing_service or replicating its logic,
# for now, a generic "not found" is sufficient if get_share returns None.
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
'request': request,
'error': 'Share link is invalid, expired, or deactivated.'
})
# Now verify access with the retrieved share object
if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email):
# Determine more specific access denial reasons
error_message = 'Access to this share is denied.'
# Check for password requirement
if share.get("password_hash") and not password:
error_message = 'This share is password protected. Please provide the correct password.'
elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]:
error_message = 'Incorrect password for this share.'
# Check for private scope and recipient
elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email:
recipients = await file_service.sharing_service._load_share_recipients(share_id)
if accessor_email not in recipients:
error_message = 'You are not authorized to access this private share.'
# Check for account-based scope and anonymous access
elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email:
error_message = 'This share requires you to be logged in to an authorized account.'
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
'request': request,
'error': error_message
})
# If access is verified, record it
await file_service.sharing_service.record_share_access(share_id, accessor_email)
# Proceed with getting the shared item details
shared_item = share # Use the already fetched share object
metadata = await file_service._load_metadata(shared_item['owner_email'])
item_meta = metadata.get(shared_item['item_path'])
if not item_meta:
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
'request': request,
'error': 'The shared item could not be found or has been removed.'
})
is_folder = item_meta.get('type') == 'dir'
if is_folder:
files = await file_service.get_shared_folder_content(share_id, password, accessor_email)
return aiohttp_jinja2.render_template('pages/share_folder.html', request, {
'request': request,
'share_id': share_id,
'item_path': shared_item['item_path'],
'files': files,
'permission': shared_item.get('permission', 'view'),
'disable_download': shared_item.get('disable_download', False)
})
else:
return aiohttp_jinja2.render_template('pages/share_file.html', request, {
'request': request,
'share_id': share_id,
'item_path': shared_item['item_path'],
'file_name': item_meta.get('name', shared_item['item_path'].split('/')[-1]),
'file_size': item_meta.get('size', 0),
'permission': shared_item.get('permission', 'view'),
'disable_download': shared_item.get('disable_download', False)
})
async def download_shared_file(request: web.Request):
share_id = request.match_info['share_id']
file_service = request.app['file_service']
password = request.query.get('password')
accessor_email = request.get('user', {}).get('email') if request.get('user') else None
requested_file_path = request.query.get('file_path')
# First, verify access to the share itself
share = await file_service.sharing_service.get_share(share_id)
if not share:
return web.Response(text='Share link is invalid, expired, or deactivated.', status=404)
if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email):
error_message = 'Access to this share is denied.'
if share.get("password_hash") and not password:
error_message = 'This share is password protected. Please provide the correct password.'
elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]:
error_message = 'Incorrect password for this share.'
elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email:
recipients = await file_service.sharing_service._load_share_recipients(share_id)
if accessor_email not in recipients:
error_message = 'You are not authorized to access this private share.'
elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email:
error_message = 'This share requires you to be logged in to an authorized account.'
return web.Response(text=error_message, status=403) # Use 403 Forbidden for access denied
# If access is verified, record it
await file_service.sharing_service.record_share_access(share_id, accessor_email)
# Then attempt to get the file content
result = await file_service.get_shared_file_content(share_id, password, accessor_email, requested_file_path)
if not result:
# This means the file itself was not found or download was disabled
if share.get("disable_download", False):
return web.Response(text='Download is disabled for this share.', status=403)
return web.Response(text='The requested file could not be found or has been removed.', status=404)
content, filename = result
return web.Response(
body=content,
headers={
'Content-Type': 'application/octet-stream',
'Content-Disposition': f'attachment; filename="{filename}"'
}
)
@login_required
@aiohttp_jinja2.template('pages/manage_shares.html')
async def manage_shares(request: web.Request):
import datetime
user = request['user']
user_email = user['email']
file_service = request.app['file_service']
sharing_service = file_service.sharing_service
shares = await sharing_service.list_user_shares(user_email)
return {
'request': request,
'user_email': user_email,
'shares': shares,
'active_page': 'my_shares',
'user': user,
'now': datetime.datetime.now(datetime.timezone.utc).isoformat()
}
@login_required
async def get_share_details(request: web.Request):
user = request['user']
user_email = user['email']
share_id = request.match_info['share_id']
file_service = request.app['file_service']
sharing_service = file_service.sharing_service
share = await sharing_service.get_share(share_id)
if not share or share['owner_email'] != user_email:
return web.json_response({'error': 'Share not found'}, status=404)
recipients = await sharing_service.get_share_recipients(share_id)
return web.json_response({
'share': share,
'recipients': list(recipients.values())
})
@login_required
async def update_share(request: web.Request):
user = request['user']
user_email = user['email']
share_id = request.match_info['share_id']
try:
data = await request.json()
except json.JSONDecodeError:
return web.json_response({'error': 'Invalid JSON'}, status=400)
file_service = request.app['file_service']
sharing_service = file_service.sharing_service
action = data.get('action')
if action == 'update_permission':
permission = data.get('permission')
success = await sharing_service.update_share_permission(user_email, share_id, permission)
elif action == 'update_expiration':
expiration_days = data.get('expiration_days')
success = await sharing_service.update_share_expiration(user_email, share_id, expiration_days)
elif action == 'deactivate':
success = await sharing_service.deactivate_share(user_email, share_id)
elif action == 'reactivate':
success = await sharing_service.reactivate_share(user_email, share_id)
elif action == 'delete':
success = await sharing_service.delete_share(user_email, share_id)
elif action == 'add_recipient':
recipient_email = data.get('recipient_email')
permission = data.get('permission', 'view')
success = await sharing_service.add_share_recipient(user_email, share_id, recipient_email, permission)
elif action == 'remove_recipient':
recipient_email = data.get('recipient_email')
success = await sharing_service.remove_share_recipient(user_email, share_id, recipient_email)
elif action == 'update_recipient_permission':
recipient_email = data.get('recipient_email')
permission = data.get('permission')
success = await sharing_service.update_recipient_permission(user_email, share_id, recipient_email, permission)
else:
return web.json_response({'error': 'Invalid action'}, status=400)
if success:
return web.json_response({'success': True})
else:
return web.json_response({'error': 'Operation failed'}, status=400)
@login_required
async def get_item_shares(request: web.Request):
user = request['user']
user_email = user['email']
item_path = request.query.get('item_path')
if not item_path:
return web.json_response({'error': 'item_path is required'}, status=400)
file_service = request.app['file_service']
sharing_service = file_service.sharing_service
shares = await sharing_service.get_shares_for_item(user_email, item_path)
return web.json_response({'shares': shares})
+81 -1
View File
@@ -38,6 +38,18 @@ class SiteView(web.View):
return await self.terms()
elif self.request.path == "/privacy":
return await self.privacy()
elif self.request.path == "/cookies":
return await self.cookies()
elif self.request.path == "/impressum":
return await self.impressum()
elif self.request.path == "/user_rights":
return await self.user_rights()
elif self.request.path == "/aup":
return await self.aup()
elif self.request.path == "/sla":
return await self.sla()
elif self.request.path == "/compliance":
return await self.compliance()
elif self.request.path == "/shared":
return await self.shared()
elif self.request.path == "/recent":
@@ -92,6 +104,36 @@ class SiteView(web.View):
"pages/privacy.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def cookies(self):
return aiohttp_jinja2.render_template(
"pages/cookies.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def impressum(self):
return aiohttp_jinja2.render_template(
"pages/impressum.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def user_rights(self):
return aiohttp_jinja2.render_template(
"pages/user_rights.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def aup(self):
return aiohttp_jinja2.render_template(
"pages/aup.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def sla(self):
return aiohttp_jinja2.render_template(
"pages/sla.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
async def compliance(self):
return aiohttp_jinja2.render_template(
"pages/compliance.html", self.request, {"request": self.request, "errors": {}, "user": self.request.get("user")}
)
@login_required
async def shared(self):
return aiohttp_jinja2.render_template(
@@ -100,8 +142,22 @@ class SiteView(web.View):
@login_required
async def recent(self):
user_email = self.request["user"]["email"]
file_service = self.request.app["file_service"]
recent_files = await file_service.get_recent_files(user_email)
# Determine editable and viewable files based on extension
editable_extensions = {'.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.xml', '.yaml', '.yml', '.ini', '.cfg', '.log', '.sh', '.bat', '.ps1', '.php', '.rb', '.java', '.c', '.cpp', '.h', '.hpp'}
viewable_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv', '.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'}
for item in recent_files:
if not item['is_dir']:
from pathlib import Path
ext = Path(item['name']).suffix.lower()
item['is_editable'] = ext in editable_extensions
item['is_viewable'] = ext in viewable_extensions
return aiohttp_jinja2.render_template(
"pages/recent.html", self.request, {"request": self.request, "errors": {}, "user": self.request["user"], "active_page": "recent"}
"pages/recent.html", self.request, {"request": self.request, "errors": {}, "user": self.request["user"], "active_page": "recent", "recent_files": recent_files}
)
@login_required
@@ -141,6 +197,17 @@ class FileBrowserView(web.View):
path = self.request.query.get("path", "")
files = await file_service.list_files(user_email, path)
# Determine editable and viewable files based on extension
editable_extensions = {'.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.xml', '.yaml', '.yml', '.ini', '.cfg', '.log', '.sh', '.bat', '.ps1', '.php', '.rb', '.java', '.c', '.cpp', '.h', '.hpp'}
viewable_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv', '.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'}
user_service = self.request.app["user_service"]
for item in files:
if not item['is_dir']:
ext = Path(item['name']).suffix.lower()
item['is_editable'] = ext in editable_extensions
item['is_viewable'] = ext in viewable_extensions
item['is_favorite'] = await user_service.is_favorite(user_email, item['path'])
success_message = self.request.query.get("success")
error_message = self.request.query.get("error")
@@ -297,6 +364,19 @@ class FileBrowserView(web.View):
logger.error(f"FileBrowserView: Failed to generate any share links for user {user_email}")
return json_response({"error": "Failed to generate share links for any selected items"}, status=500)
elif route_name == "toggle_favorite":
data = await self.request.json()
file_path = data.get("file_path")
if not file_path:
return json_response({"error": "File path is required"}, status=400)
user_service = self.request.app["user_service"]
is_fav = await user_service.is_favorite(user_email, file_path)
if is_fav:
await user_service.remove_favorite(user_email, file_path)
else:
await user_service.add_favorite(user_email, file_path)
return json_response({"is_favorite": not is_fav})
logger.warning(f"FileBrowserView: Unknown file action for POST request: {route_name}")
raise web.HTTPBadRequest(text="Unknown file action")
+27 -13
View File
@@ -9,35 +9,49 @@ class UploadView(web.View):
async def post(self):
user_email = self.request["user"]["email"]
file_service = self.request.app["file_service"]
# Get current path from query parameter or form data
current_path = self.request.query.get("current_path", "")
current_path = ""
try:
reader = await self.request.multipart()
files_uploaded = []
errors = []
pending_files = []
while True:
field = await reader.next()
if field is None:
break
# Check if the field is a file input
if field.name == "file": # Assuming the input field name is 'file'
if field.name == "current_path":
current_path = (await field.read()).decode('utf-8').strip()
print(f"Upload: current_path received: '{current_path}'")
continue
if field.name == "file":
filename = field.filename
if not filename:
errors.append("Filename is required for one of the files.")
continue
content = await field.read()
# Construct the full file path relative to the user's base directory
full_file_path_for_service = f"{current_path}/{filename}" if current_path else filename
success = await file_service.upload_file(user_email, full_file_path_for_service, content)
if success:
files_uploaded.append(filename)
else:
errors.append(f"Failed to upload file '{filename}'")
pending_files.append((filename, content))
print(f"Upload: Processing {len(pending_files)} files to path: '{current_path}'")
for filename, content in pending_files:
if current_path and not current_path.endswith('/'):
full_file_path_for_service = f"{current_path}/{filename}"
elif current_path:
full_file_path_for_service = f"{current_path}{filename}"
else:
full_file_path_for_service = filename
print(f"Upload: Uploading file to: {full_file_path_for_service}")
success = await file_service.upload_file(user_email, full_file_path_for_service, content)
if success:
files_uploaded.append(filename)
else:
errors.append(f"Failed to upload file '{filename}'")
if errors:
return json_response({"status": "error", "message": "Some files failed to upload", "details": errors}, status=500)
+44
View File
@@ -0,0 +1,44 @@
from aiohttp import web
import aiohttp_jinja2
from pathlib import Path
from ..helpers.auth import login_required
class ViewerView(web.View):
@login_required
async def get(self):
file_path = self.request.query.get('path', '')
user = self.request.get('user')
if not file_path:
return web.Response(text="No file path specified", status=400)
# Get file extension to determine media type
file_ext = Path(file_path).suffix.lower()
# Determine media type
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'}
video_extensions = {'.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv'}
audio_extensions = {'.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'}
if file_ext in image_extensions:
media_type = 'image'
elif file_ext in video_extensions:
media_type = 'video'
elif file_ext in audio_extensions:
media_type = 'audio'
else:
return web.Response(text="Unsupported file type for viewing", status=400)
return aiohttp_jinja2.render_template(
'pages/media_viewer.html',
self.request,
{
'request': self.request,
'user': user,
'file_path': file_path,
'media_type': media_type,
'active_page': 'files'
}
)
+5 -4
View File
@@ -1,17 +1,18 @@
from setuptools import setup, find_packages
with open('requirements.txt') as f:
install_requires = f.read().splitlines()
setup(
name="retoors",
version="0.1.0",
packages=find_packages(),
include_package_data=True,
install_requires=[
"aiohttp",
"jinja2",
],
install_requires=install_requires,
entry_points={
"console_scripts": [
"retoors=retoors.main:main",
],
},
)