Compare commits
35
Commits
main
..
925f91a17c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925f91a17c | ||
|
|
7abced9315 | ||
|
|
f54941dd80 | ||
|
|
059457deae | ||
|
|
5d3d0b162d | ||
|
|
ea8af383cc | ||
|
|
6e47d43a03 | ||
|
|
88d57c3837 | ||
|
|
9e9907bc00 | ||
|
|
c6fb77c89d | ||
|
|
81f1cfd200 | ||
|
|
e228a2e59c | ||
|
|
8d740be5fb | ||
|
|
6248a2086c | ||
|
|
a6a19b8438 | ||
|
|
2ad7401226 | ||
|
|
e77b2d851d | ||
|
|
2d6debe744 | ||
|
|
30f8204e68 | ||
|
|
fc10b535b2 | ||
|
|
1cdd8fba11 | ||
|
|
993b5bfbb6 | ||
|
|
8472811913 | ||
|
|
a7d8613dd6 | ||
|
|
748213538f | ||
|
|
a1b20e2fc2 | ||
|
|
fc6a555a33 | ||
|
|
a0a52fcaa2 | ||
|
|
56b9aaa021 | ||
|
|
83ac1eb001 | ||
|
|
ed4cd5c14f | ||
|
|
69f5e0465d | ||
|
|
02465f721e | ||
|
|
6c4dfc1855 | ||
|
|
67cf0e1cea |
-31
@@ -1,31 +0,0 @@
|
||||
# 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"]
|
||||
@@ -1,17 +0,0 @@
|
||||
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
|
||||
+8
-15
@@ -1,20 +1,13 @@
|
||||
aiohttp
|
||||
jinja2
|
||||
pydantic
|
||||
pytest
|
||||
pytest-asyncio
|
||||
aiofiles
|
||||
pytest-cov
|
||||
rich
|
||||
aiohttp_jinja2
|
||||
pydantic[dotenv]
|
||||
python-dotenv
|
||||
pydantic[email]
|
||||
aiohttp_session
|
||||
cryptography
|
||||
bcrypt
|
||||
python-dotenv
|
||||
aiosmtplib
|
||||
aiojobs
|
||||
aiofiles
|
||||
aiohttp_pydantic
|
||||
bcrypt
|
||||
aiosmtplib
|
||||
|
||||
pytest
|
||||
pytest-aiohttp
|
||||
aiohttp-test-utils
|
||||
pytest-mock
|
||||
pillow
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
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
|
||||
@@ -26,12 +24,11 @@ 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 = MIMEMultipart('alternative')
|
||||
msg = EmailMessage()
|
||||
msg["From"] = smtp_sender_email
|
||||
msg["To"] = recipient_email
|
||||
msg["Subject"] = subject
|
||||
html_part = MIMEText(body, 'html')
|
||||
msg.attach(html_part)
|
||||
msg.set_content(body)
|
||||
|
||||
try:
|
||||
await aiosmtplib.send(
|
||||
|
||||
+2
-10
@@ -1,5 +1,3 @@
|
||||
import os
|
||||
import argparse
|
||||
from aiohttp import web
|
||||
import aiohttp_jinja2
|
||||
import jinja2
|
||||
@@ -22,7 +20,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", app["user_service"])
|
||||
app["file_service"] = FileService(data_path / "user_files", data_path / "users.json")
|
||||
|
||||
# Setup aiojobs scheduler
|
||||
app["scheduler"] = aiojobs.Scheduler()
|
||||
@@ -69,15 +67,9 @@ 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, host=args.hostname, port=args.port)
|
||||
web.run_app(app)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+4
-31
@@ -3,18 +3,7 @@ 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")
|
||||
@@ -23,18 +12,15 @@ 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")
|
||||
@@ -54,10 +40,6 @@ 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")
|
||||
@@ -66,12 +48,3 @@ 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")
|
||||
|
||||
@@ -8,7 +8,6 @@ import logging
|
||||
import hashlib
|
||||
import os
|
||||
from .storage_service import StorageService
|
||||
from .sharing_service import SharingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@@ -22,7 +21,6 @@ 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)
|
||||
@@ -65,12 +63,6 @@ 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():
|
||||
@@ -99,7 +91,6 @@ 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}")
|
||||
@@ -124,7 +115,6 @@ 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}")
|
||||
@@ -137,9 +127,6 @@ 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():
|
||||
@@ -150,69 +137,6 @@ 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)
|
||||
@@ -227,92 +151,77 @@ 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,
|
||||
permission: str = "view",
|
||||
scope: str = "public",
|
||||
password: str = None,
|
||||
expiration_days: int = None,
|
||||
disable_download: bool = False,
|
||||
recipient_emails: list = None
|
||||
) -> str | None:
|
||||
|
||||
async def generate_share_link(self, user_email: str, item_path: str) -> str | None:
|
||||
"""Generates a shareable link for a file or folder."""
|
||||
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 = 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
|
||||
)
|
||||
|
||||
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"])
|
||||
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, password: str = None, accessor_email: str = None) -> dict | None:
|
||||
|
||||
async def get_shared_item(self, share_id: str) -> dict | None:
|
||||
"""Retrieves information about a shared item."""
|
||||
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, password, accessor_email)
|
||||
shared_item = await self.get_shared_item(share_id)
|
||||
if not shared_item:
|
||||
return None
|
||||
|
||||
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"]
|
||||
user_email = shared_item["user_email"]
|
||||
item_path = shared_item["item_path"] # This is the path of the originally shared item (file or folder)
|
||||
|
||||
target_path = item_path
|
||||
if requested_file_path:
|
||||
target_path = requested_file_path
|
||||
if not target_path.startswith(item_path + '/') and target_path != item_path:
|
||||
# Security check: Ensure the requested file is actually within the shared item's directory
|
||||
if not target_path.startswith(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, password: str = None, accessor_email: str = None) -> list | None:
|
||||
|
||||
async def get_shared_folder_content(self, share_id: str) -> list | None:
|
||||
"""Retrieves the content of a shared folder."""
|
||||
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, password, accessor_email)
|
||||
shared_item = await self.get_shared_item(share_id)
|
||||
if not shared_item:
|
||||
return None
|
||||
|
||||
user_email = shared_item["owner_email"]
|
||||
user_email = shared_item["user_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
|
||||
@@ -337,7 +246,6 @@ 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:
|
||||
@@ -365,28 +273,9 @@ 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]
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
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
|
||||
@@ -1,317 +0,0 @@
|
||||
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
|
||||
@@ -4,7 +4,6 @@ import hashlib
|
||||
import aiofiles
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from .lock_manager import get_lock_manager
|
||||
|
||||
|
||||
class StorageService:
|
||||
@@ -12,7 +11,6 @@ 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()
|
||||
@@ -45,10 +43,8 @@ class StorageService:
|
||||
|
||||
file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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))
|
||||
async with aiofiles.open(file_path, 'w') as f:
|
||||
await f.write(json.dumps(data, indent=2))
|
||||
|
||||
return True
|
||||
|
||||
@@ -62,16 +58,14 @@ class StorageService:
|
||||
if not file_path.exists():
|
||||
return None
|
||||
|
||||
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 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)
|
||||
@@ -81,11 +75,8 @@ class StorageService:
|
||||
raise ValueError("Invalid path: directory traversal detected")
|
||||
|
||||
if file_path.exists():
|
||||
lock = await self.lock_manager.get_lock(str(file_path))
|
||||
async with lock:
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
return True
|
||||
file_path.unlink()
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@@ -107,11 +98,9 @@ class StorageService:
|
||||
results = []
|
||||
for json_file in user_base.rglob("*.json"):
|
||||
if self._validate_path(json_file, user_base):
|
||||
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))
|
||||
async with aiofiles.open(json_file, 'r') as f:
|
||||
content = await f.read()
|
||||
results.append(json.loads(content))
|
||||
|
||||
return results
|
||||
|
||||
@@ -133,7 +122,6 @@ 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)
|
||||
@@ -158,13 +146,11 @@ class UserStorageManager:
|
||||
if user_dir.is_dir():
|
||||
user_files = list(user_dir.rglob("*.json"))
|
||||
for user_file in user_files:
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -179,10 +165,8 @@ class UserStorageManager:
|
||||
if user_dir.is_dir():
|
||||
user_files = list(user_dir.rglob("*.json"))
|
||||
for user_file in user_files:
|
||||
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))
|
||||
async with aiofiles.open(user_file, 'r') as f:
|
||||
content = await f.read()
|
||||
all_users.append(json.loads(content))
|
||||
|
||||
return all_users
|
||||
|
||||
@@ -5,13 +5,11 @@ 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()
|
||||
@@ -29,13 +27,11 @@ class UserService:
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
async def _save_users(self):
|
||||
def _save_users(self):
|
||||
if self.use_isolated_storage:
|
||||
return
|
||||
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)
|
||||
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:
|
||||
@@ -84,7 +80,7 @@ class UserService:
|
||||
await self._storage_manager.save_user(email, user)
|
||||
else:
|
||||
self._users.append(user)
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
|
||||
return user
|
||||
|
||||
@@ -102,7 +98,7 @@ class UserService:
|
||||
if self.use_isolated_storage:
|
||||
await self._storage_manager.save_user(email, user)
|
||||
else:
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
|
||||
return user
|
||||
|
||||
@@ -113,7 +109,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:
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -130,7 +126,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:
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
|
||||
return deleted_count
|
||||
|
||||
@@ -164,7 +160,7 @@ class UserService:
|
||||
if self.use_isolated_storage:
|
||||
await self._storage_manager.save_user(email, user)
|
||||
else:
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
|
||||
return token
|
||||
|
||||
@@ -196,7 +192,7 @@ class UserService:
|
||||
if self.use_isolated_storage:
|
||||
await self._storage_manager.save_user(email, user)
|
||||
else:
|
||||
await self._save_users()
|
||||
self._save_users()
|
||||
|
||||
return True
|
||||
|
||||
@@ -210,49 +206,4 @@ class UserService:
|
||||
if self.use_isolated_storage:
|
||||
await self._storage_manager.save_user(email, user)
|
||||
else:
|
||||
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", [])
|
||||
self._save_users()
|
||||
|
||||
+125
-282
@@ -1,102 +1,86 @@
|
||||
:root {
|
||||
--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 */
|
||||
--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 */
|
||||
|
||||
--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);
|
||||
/* 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 */
|
||||
}
|
||||
|
||||
html, body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
font-family: 'Roboto', sans-serif; /* Using a more modern font */
|
||||
background-color: var(--background-color);
|
||||
color: var(--text-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.6;
|
||||
line-height: 1.6; /* Improve readability */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* General typography */
|
||||
h1 {
|
||||
font-size: clamp(1.75rem, 5vw, 3.5rem);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-size: 2.2rem; /* Slightly larger heading */
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
line-height: 1.3;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.8rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: clamp(1.125rem, 3vw, 1.5rem);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.6rem;
|
||||
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;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
padding: clamp(1rem, 4vw, 3rem);
|
||||
margin-bottom: clamp(20px, 3vw, 35px);
|
||||
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 */
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column; /* Allow content to stack vertically */
|
||||
justify-content: flex-start; /* Align content to the top */
|
||||
align-items: center;
|
||||
padding: clamp(10px, 2vw, 20px);
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%; /* Ensure container takes full width */
|
||||
max-width: 1200px; /* Max width for overall content */
|
||||
margin: 0 auto; /* Center the container */
|
||||
}
|
||||
|
||||
.retoors-container {
|
||||
@@ -133,8 +117,8 @@ p {
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
box-shadow: 0 1px 6px rgba(174, 28, 40, 0.25);
|
||||
border-color: var(--dutch-red);
|
||||
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||
border-color: var(--accent-color);
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
@@ -144,10 +128,10 @@ p {
|
||||
}
|
||||
|
||||
.retoors-button {
|
||||
background-color: #F0F0F0;
|
||||
border: 1px solid #D0D0D0;
|
||||
background-color: var(--btn-secondary-bg);
|
||||
border: 1px solid var(--btn-secondary-bg);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color);
|
||||
color: var(--btn-secondary-text);
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 15px;
|
||||
padding: 10px 20px;
|
||||
@@ -158,110 +142,101 @@ p {
|
||||
}
|
||||
|
||||
.retoors-button:hover {
|
||||
background-color: #E0E0E0;
|
||||
border-color: #B0B0B0;
|
||||
background-color: var(--btn-secondary-hover-bg);
|
||||
border-color: var(--btn-secondary-hover-bg);
|
||||
}
|
||||
|
||||
/* Header and Navigation */
|
||||
.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);
|
||||
header {
|
||||
background-color: var(--card-background); /* White background for header */
|
||||
box-shadow: 0 2px 4px var(--shadow-color);
|
||||
padding: 15px 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
min-height: 70px;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
text-decoration: none;
|
||||
nav .logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
nav .logo img {
|
||||
height: 30px; /* Adjust logo size */
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
nav .nav-links {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
gap: 2rem;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
}
|
||||
|
||||
.nav-menu li {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
nav .nav-links a {
|
||||
color: var(--text-color);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-menu a:hover {
|
||||
color: var(--dutch-red);
|
||||
nav .nav-links a:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-color);
|
||||
.btn-primary-nav {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
font-size: 1rem;
|
||||
transition: color 0.2s ease;
|
||||
white-space: nowrap;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--dutch-red);
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
main {
|
||||
flex-grow: 1;
|
||||
flex-grow: 1; /* Allow main content to take available space */
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-width: 1200px; /* Match header max-width */
|
||||
margin: 0 auto; /* Center the main content */
|
||||
padding: 40px 20px; /* Add padding */
|
||||
box-sizing: border-box;
|
||||
background-color: var(--background-color);
|
||||
background-color: var(--background-color); /* Light background for main content */
|
||||
}
|
||||
|
||||
/* Form specific styles */
|
||||
@@ -269,17 +244,17 @@ main {
|
||||
max-width: 450px;
|
||||
margin: 50px auto;
|
||||
background-color: var(--card-background);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
|
||||
padding: 2.5rem;
|
||||
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 */
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.form-container h2 {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
margin-bottom: 35px; /* Increased margin */
|
||||
color: var(--text-color);
|
||||
font-size: 2rem;
|
||||
font-size: 2.2rem; /* Slightly larger heading */
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -334,17 +309,16 @@ main {
|
||||
.btn-small,
|
||||
.btn-danger {
|
||||
display: inline-block;
|
||||
padding: 0.65rem 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
padding: 0.6rem 1.2rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@@ -356,8 +330,6 @@ 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 {
|
||||
@@ -370,7 +342,6 @@ main {
|
||||
background-color: var(--btn-outline-hover-bg);
|
||||
color: var(--btn-outline-text);
|
||||
border-color: var(--btn-outline-border);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@@ -379,27 +350,14 @@ main {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--red-accent);
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
border-color: var(--red-accent);
|
||||
border-color: #dc3545;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
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);
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -414,143 +372,28 @@ main {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 992px) {
|
||||
.nav-menu {
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive adjustments for base.css */
|
||||
@media (max-width: 768px) {
|
||||
.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;
|
||||
nav {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
order: 2;
|
||||
gap: 15px;
|
||||
}
|
||||
nav .logo {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
flex: 1 1 100%;
|
||||
nav .nav-links {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
order: 3;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
.btn-primary-nav {
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
main {
|
||||
padding: 20px 15px;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,3 @@
|
||||
/* 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;
|
||||
|
||||
@@ -10,27 +10,15 @@
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
flex: 0 0 250px;
|
||||
flex: 0 0 250px; /* Fixed width sidebar */
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px var(--shadow-color);
|
||||
padding: 20px;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
.sidebar-menu ul {
|
||||
@@ -55,9 +43,8 @@
|
||||
|
||||
.sidebar-menu ul li a:hover,
|
||||
.sidebar-menu ul li a.active {
|
||||
background-color: var(--dutch-blue);
|
||||
color: var(--dutch-white);
|
||||
border-left: 3px solid var(--dutch-red);
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.sidebar-menu ul li a img.icon {
|
||||
@@ -167,15 +154,14 @@
|
||||
}
|
||||
|
||||
.file-search-bar:focus {
|
||||
border-color: var(--dutch-red);
|
||||
box-shadow: 0 0 0 2px rgba(174, 28, 40, 0.2);
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.2);
|
||||
}
|
||||
|
||||
.file-list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.file-list-table th,
|
||||
@@ -195,9 +181,6 @@
|
||||
|
||||
.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 {
|
||||
@@ -217,43 +200,6 @@
|
||||
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 {
|
||||
@@ -300,54 +246,7 @@
|
||||
|
||||
.file-list-table th,
|
||||
.file-list-table td {
|
||||
padding: 8px 10px;
|
||||
padding: 10px 12px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,5 @@
|
||||
.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 !important;
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
@@ -25,10 +10,6 @@
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--card-background);
|
||||
margin: 10% auto;
|
||||
@@ -180,7 +161,6 @@
|
||||
.file-list-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.file-list-table th {
|
||||
@@ -197,9 +177,6 @@
|
||||
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 {
|
||||
@@ -220,46 +197,12 @@
|
||||
.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%;
|
||||
@@ -284,10 +227,6 @@
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.file-list-table td {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.dashboard-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
@@ -1,72 +1,14 @@
|
||||
footer {
|
||||
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-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 {
|
||||
background-color: var(--card-background); /* Consistent with header */
|
||||
color: var(--light-text-color); /* Subtle text color */
|
||||
padding: 1.5rem 2rem;
|
||||
text-align: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
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 */
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
footer p {
|
||||
margin: 0; /* Remove default paragraph margin */
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
@@ -1,106 +1 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,533 +1,147 @@
|
||||
/* Hero Section */
|
||||
/* Styles for the Homepage (index.html) */
|
||||
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
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;
|
||||
padding: 60px 20px;
|
||||
/* Removed background-color and box-shadow to match image */
|
||||
margin-bottom: 40px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 3rem;
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
margin-bottom: 0.5rem; /* Adjusted margin to match image */
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
.hero-section p {
|
||||
font-size: 1.3rem;
|
||||
color: var(--light-text-color);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
max-width: 800px;
|
||||
margin: 0 auto 2.5rem auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.5rem;
|
||||
.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 */
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
.benefit-card {
|
||||
background-color: var(--card-background);
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.hero-subtext {
|
||||
font-size: 0.875rem;
|
||||
color: var(--light-text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Features Section */
|
||||
.features-section {
|
||||
padding: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px);
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.features-header {
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
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);
|
||||
}
|
||||
|
||||
.feature-card p {
|
||||
font-size: 1rem;
|
||||
color: var(--light-text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Pricing Calculator Section */
|
||||
.pricing-calculator {
|
||||
padding: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px);
|
||||
background: #FFFFFF;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.calculator-content {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.pricing-calculator h2 {
|
||||
font-size: 2.25rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
|
||||
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;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
min-height: 250px; /* Ensure cards have a consistent height */
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: clamp(1.25rem, 4vw, 2rem);
|
||||
color: var(--text-color);
|
||||
margin-right: 0.25rem;
|
||||
.benefit-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: clamp(2rem, 8vw, 4rem);
|
||||
font-weight: 700;
|
||||
color: var(--dutch-red);
|
||||
.benefit-card img.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 20px;
|
||||
/* Icons in the image are colored, not inheriting text color */
|
||||
}
|
||||
|
||||
.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;
|
||||
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 {
|
||||
.benefit-card h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.use-case-card p {
|
||||
.benefit-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);
|
||||
/* Specific card colors from the image */
|
||||
.family-card {
|
||||
background-color: #D0E6F0; /* Light blue */
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
.professional-card {
|
||||
background-color: #F0E0D0; /* Light orange */
|
||||
}
|
||||
|
||||
.cta-section h2 {
|
||||
font-size: 2.75rem;
|
||||
color: var(--dutch-white);
|
||||
margin-bottom: 1rem;
|
||||
.student-card {
|
||||
background-color: #D0F0D0; /* Light green */
|
||||
}
|
||||
|
||||
.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;
|
||||
.find-plan-btn {
|
||||
margin-top: 40px;
|
||||
padding: 15px 30px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
background-color: var(--dutch-white);
|
||||
color: var(--dutch-blue);
|
||||
border-radius: 8px;
|
||||
border-radius: 5px;
|
||||
background-color: #4A90E2; /* Blue from the image */
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, background-color 0.3s ease;
|
||||
border: 2px solid var(--dutch-red);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.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);
|
||||
.find-plan-btn:hover {
|
||||
background-color: #3A7BD5; /* Darker blue on hover */
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
/* Responsive adjustments for index.css */
|
||||
@media (max-width: 992px) {
|
||||
.hero-section h1 {
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.features-header h2,
|
||||
.use-cases-section h2,
|
||||
.cta-section h2 {
|
||||
font-size: 2rem;
|
||||
.hero-section p {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
padding: 60px 20px 50px;
|
||||
padding: 40px 15px;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.125rem;
|
||||
.hero-section p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-ctas {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
.benefits-grid {
|
||||
grid-template-columns: 1fr; /* Stack cards on small screens */
|
||||
}
|
||||
|
||||
.hero-btn {
|
||||
.benefit-card {
|
||||
padding: 25px;
|
||||
}
|
||||
.find-plan-btn {
|
||||
width: 100%;
|
||||
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;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-section {
|
||||
padding: 40px 15px 30px;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.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 {
|
||||
.hero-section p {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
.hero-ctas .btn-primary, .hero-ctas .btn-outline {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
@@ -4,33 +4,31 @@
|
||||
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);
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.login-page-container h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-page-container .subtitle {
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
font-size: 1.1rem;
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 450px;
|
||||
max-width: 450px; /* Adjust as needed */
|
||||
width: 100%;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
padding: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
@@ -44,7 +42,7 @@
|
||||
}
|
||||
|
||||
.forgot-password-link:hover {
|
||||
color: var(--dutch-red);
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -62,46 +60,19 @@
|
||||
}
|
||||
|
||||
.create-account-link a:hover {
|
||||
color: var(--dutch-red);
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.login-page-container {
|
||||
padding: 30px 15px 40px;
|
||||
}
|
||||
.login-page-container h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.login-page-container .subtitle {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-container {
|
||||
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;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,59 +373,4 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -4,33 +4,31 @@
|
||||
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);
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.register-page-container h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-page-container .subtitle {
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
font-size: 1.1rem;
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
margin-bottom: 30px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 450px;
|
||||
max-width: 450px; /* Adjust as needed */
|
||||
width: 100%;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
padding: 2.5rem;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
}
|
||||
|
||||
.terms-checkbox {
|
||||
@@ -46,16 +44,16 @@
|
||||
margin-right: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--dutch-red);
|
||||
accent-color: var(--accent-color); /* Style checkbox with accent color */
|
||||
}
|
||||
|
||||
.terms-checkbox a {
|
||||
color: var(--dutch-blue);
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terms-checkbox a:hover {
|
||||
color: var(--dutch-red);
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -73,46 +71,19 @@
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
color: var(--dutch-red);
|
||||
color: var(--accent-color);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.register-page-container {
|
||||
padding: 30px 15px 40px;
|
||||
}
|
||||
.register-page-container h1 {
|
||||
font-size: 1.75rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.register-page-container .subtitle {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.form-container {
|
||||
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;
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* 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;
|
||||
}
|
||||
}
|
||||
@@ -169,53 +169,7 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
|
||||
.use-cases-hero {
|
||||
text-align: center;
|
||||
padding: clamp(20px, 5vw, 40px) clamp(10px, 3vw, 20px);
|
||||
margin-bottom: clamp(30px, 6vw, 60px);
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.use-cases-hero h1 {
|
||||
font-size: clamp(1.75rem, 5vw, 3rem);
|
||||
font-size: 3rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.use-cases-hero p {
|
||||
font-size: clamp(0.95rem, 2.5vw, 1.2rem);
|
||||
font-size: 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(min(100%, 300px), 1fr));
|
||||
gap: clamp(20px, 3vw, 30px);
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto clamp(30px, 6vw, 60px) auto;
|
||||
padding: 0 clamp(10px, 3vw, 20px);
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
@@ -127,52 +127,9 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.use-cases-hero h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.use-cases-hero p {
|
||||
font-size: 0.9rem;
|
||||
font-size: 2rem;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -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}">🗑️</button>
|
||||
<button class="btn-outline delete-user-btn" data-email="${user.email}">Delete User</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}">🗑️</button>` : ''}
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">Delete Team</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
userQuotaList.appendChild(quotaItem);
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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;
|
||||
@@ -1,11 +1,9 @@
|
||||
export function showUploadModal() {
|
||||
const modal = document.getElementById('upload-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('show');
|
||||
}
|
||||
document.getElementById('upload-modal').style.display = 'block';
|
||||
// Clear previous selections and progress
|
||||
document.getElementById('selected-files-preview').innerHTML = '';
|
||||
document.getElementById('upload-progress-container').innerHTML = '';
|
||||
document.getElementById('file-input-multiple').value = '';
|
||||
document.getElementById('file-input-multiple').value = ''; // Clear selected files
|
||||
document.getElementById('start-upload-btn').disabled = true;
|
||||
}
|
||||
|
||||
@@ -15,11 +13,6 @@ 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) => {
|
||||
@@ -62,94 +55,62 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
|
||||
async function uploadFiles(files) {
|
||||
startUploadBtn.disabled = true;
|
||||
uploadProgressContainer.innerHTML = '';
|
||||
startUploadBtn.disabled = true; // Disable button during upload
|
||||
uploadProgressContainer.innerHTML = ''; // Clear previous progress
|
||||
|
||||
const currentPath = new URLSearchParams(window.location.search).get('path') || '';
|
||||
console.log('Uploading to directory:', currentPath || '(root)');
|
||||
|
||||
let completedUploads = 0;
|
||||
let totalFiles = files.length;
|
||||
let hasErrors = false;
|
||||
for (const file of files) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const uploadPromises = files.map(file => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const formData = new FormData();
|
||||
formData.append('current_path', currentPath);
|
||||
formData.append('file', file);
|
||||
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 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 xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `/files/upload?current_path=${encodeURIComponent(currentPath)}`, true);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
window.location.href = '/files';
|
||||
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)}%`;
|
||||
}
|
||||
}, 500);
|
||||
} catch (error) {
|
||||
console.error('Error during upload:', error);
|
||||
startUploadBtn.disabled = false;
|
||||
});
|
||||
|
||||
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)`;
|
||||
} 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`;
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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}">🗑️</button>
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">🗑️</button>` : ''}
|
||||
<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>` : ''}
|
||||
</div>
|
||||
`;
|
||||
userQuotaList.appendChild(quotaItem);
|
||||
|
||||
+51
-72
@@ -88,23 +88,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
// Helper functions for modals
|
||||
function showNewFolderModal() {
|
||||
const modal = document.getElementById('new-folder-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('show');
|
||||
}
|
||||
document.getElementById('new-folder-modal').style.display = 'block';
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
document.getElementById(modalId).style.display = 'none';
|
||||
}
|
||||
window.closeModal = closeModal;
|
||||
window.closeModal = closeModal; // Make it globally accessible
|
||||
|
||||
window.onclick = function(event) {
|
||||
if (event.target.classList.contains('modal')) {
|
||||
event.target.classList.remove('show');
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,65 +109,63 @@ 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 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');
|
||||
const shareLinksList = document.getElementById('share-links-list'); // New element for multiple links
|
||||
|
||||
quickShareResult.style.display = 'none';
|
||||
quickShareLinkInput.value = '';
|
||||
modal.classList.add('show');
|
||||
|
||||
const currentPath = paths[0];
|
||||
const currentName = names[0];
|
||||
// Clear previous content
|
||||
shareLinkInput.value = '';
|
||||
if (shareLinksList) shareLinksList.innerHTML = '';
|
||||
linkContainer.style.display = 'none';
|
||||
loading.style.display = 'block';
|
||||
modal.style.display = 'block';
|
||||
|
||||
if (paths.length === 1) {
|
||||
shareFileName.textContent = `Sharing: ${currentName}`;
|
||||
shareFileName.textContent = `Sharing: ${names[0]}`;
|
||||
} else {
|
||||
shareFileName.textContent = `Sharing ${paths.length} items`;
|
||||
}
|
||||
|
||||
generateQuickShareBtn.onclick = async function() {
|
||||
generateQuickShareBtn.disabled = true;
|
||||
generateQuickShareBtn.textContent = 'Generating...';
|
||||
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();
|
||||
|
||||
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';
|
||||
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';
|
||||
} else {
|
||||
alert('Error generating share link: ' + data.error);
|
||||
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
|
||||
// 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';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating share link:', error);
|
||||
alert('Error generating share link');
|
||||
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
|
||||
} finally {
|
||||
generateQuickShareBtn.disabled = false;
|
||||
loading.style.display = 'none';
|
||||
} else {
|
||||
loading.textContent = 'Error generating share link(s)';
|
||||
}
|
||||
};
|
||||
|
||||
advancedShareBtn.onclick = function() {
|
||||
window.location.href = `/sharing/create?item_path=${encodeURIComponent(currentPath)}`;
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error sharing files:', error);
|
||||
loading.textContent = 'Error generating share link(s)';
|
||||
}
|
||||
}
|
||||
|
||||
function copyShareLink() {
|
||||
@@ -188,6 +180,7 @@ 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) {
|
||||
@@ -206,7 +199,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.classList.add('show');
|
||||
deleteModal.style.display = 'block';
|
||||
}
|
||||
|
||||
// Selection and action buttons
|
||||
@@ -298,20 +291,6 @@ 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);
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
<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">×</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>
|
||||
@@ -1,31 +1,3 @@
|
||||
<footer aria-label="Site information and copyright">
|
||||
<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>© 2025 Retoors. All rights reserved.</p>
|
||||
</div>
|
||||
<p>© 2025 Retoors. All rights reserved.</p>
|
||||
</footer>
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
<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>
|
||||
<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>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -3,17 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Retoors Cloud Solutions{% endblock %}</title>
|
||||
<title>{% block title %}Retoors Storage{% 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">
|
||||
<link rel="stylesheet" href="/static/css/components/content_pages.css"> {# Added for content page styling #}
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% include 'components/navigation.html' %}
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
<div class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
{% include 'components/footer.html' %}
|
||||
{% include 'components/cookie_banner.html' %}
|
||||
<script src="/static/js/main.js" type="module"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
<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>
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,194 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,80 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,246 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -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!')">🗑️</button>
|
||||
<button class="btn-outline" onclick="alert('Delete feature coming soon!')">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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 }}">🗑️</button>
|
||||
<button class="btn-small btn-danger remove-favorite-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Remove</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">🗑️</button>
|
||||
<button type="submit" class="btn-danger">Remove</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('remove-favorite-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
{% extends "layouts/dashboard.html" %}
|
||||
|
||||
{% block title %}{% if current_path %}{{ current_path.split('/')[-1] }} - Retoor's Cloud Solutions{% else %}My Files - Retoor's Cloud Solutions{% endif %}{% endblock %}
|
||||
{% block title %}My Files - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block dashboard_head %}
|
||||
<link rel="stylesheet" href="/static/css/components/file_browser.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}{% if current_path %}{{ current_path.split('/')[-1] }}{% else %}My Files{% endif %}{% endblock %}
|
||||
{% block page_title %}My Files{% 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>🗑️</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
@@ -55,13 +55,7 @@
|
||||
<a href="/files?path={{ item.path }}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
<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 %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
@@ -79,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 }}">🗑️</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -131,46 +125,20 @@
|
||||
<div id="share-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal('share-modal')">×</span>
|
||||
<h3>Share Item</h3>
|
||||
<h3>Share File</h3>
|
||||
<p id="share-file-name"></p>
|
||||
|
||||
<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 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>
|
||||
|
||||
<div class="modal-actions" style="margin-top: 2rem;">
|
||||
<div id="share-loading">Generating share link...</div>
|
||||
<div class="modal-actions">
|
||||
<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')">×</span>
|
||||
@@ -178,7 +146,7 @@
|
||||
<p id="delete-message"></p>
|
||||
<form id="delete-form" method="post">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,67 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Secure Cloud Storage - Retoor's Cloud Solutions{% endblock %}
|
||||
{% block title %}Solutions for Everyone - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/css/components/index.css">
|
||||
@@ -9,45 +9,26 @@
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="hero-section">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 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>
|
||||
|
||||
<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 %}
|
||||
|
||||
@@ -1,381 +0,0 @@
|
||||
{% 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">×</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 %}
|
||||
@@ -1,135 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,81 @@
|
||||
{% 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 %}
|
||||
@@ -52,14 +52,8 @@
|
||||
<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">
|
||||
{% 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 %}
|
||||
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
@@ -125,4 +119,4 @@
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% 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 %}
|
||||
@@ -1,34 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,81 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,114 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -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>🗑️</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</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 }}">🗑️</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</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">🗑️</button>
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% 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 %}
|
||||
@@ -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>🗑️</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete Permanently</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 }}">🗑️</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete Permanently</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">🗑️</button>
|
||||
<button type="submit" class="btn-danger">Delete Permanently</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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">🗑️</button>
|
||||
<button type="submit" class="btn-danger">Delete User</button>
|
||||
</form>
|
||||
<a href="/users" class="btn-outline">Back to Users</a>
|
||||
</div>
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -1,84 +0,0 @@
|
||||
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)
|
||||
@@ -1,381 +0,0 @@
|
||||
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})
|
||||
+1
-81
@@ -38,18 +38,6 @@ 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":
|
||||
@@ -104,36 +92,6 @@ 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(
|
||||
@@ -142,22 +100,8 @@ 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", "recent_files": recent_files}
|
||||
"pages/recent.html", self.request, {"request": self.request, "errors": {}, "user": self.request["user"], "active_page": "recent"}
|
||||
)
|
||||
|
||||
@login_required
|
||||
@@ -197,17 +141,6 @@ 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")
|
||||
|
||||
@@ -364,19 +297,6 @@ 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")
|
||||
|
||||
|
||||
+13
-27
@@ -9,49 +9,35 @@ class UploadView(web.View):
|
||||
async def post(self):
|
||||
user_email = self.request["user"]["email"]
|
||||
file_service = self.request.app["file_service"]
|
||||
current_path = ""
|
||||
# Get current path from query parameter or form data
|
||||
current_path = self.request.query.get("current_path", "")
|
||||
|
||||
try:
|
||||
reader = await self.request.multipart()
|
||||
files_uploaded = []
|
||||
errors = []
|
||||
pending_files = []
|
||||
|
||||
while True:
|
||||
field = await reader.next()
|
||||
if field is None:
|
||||
break
|
||||
|
||||
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":
|
||||
|
||||
# Check if the field is a file input
|
||||
if field.name == "file": # Assuming the input field name is 'file'
|
||||
filename = field.filename
|
||||
if not filename:
|
||||
errors.append("Filename is required for one of the files.")
|
||||
continue
|
||||
|
||||
content = await field.read()
|
||||
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}'")
|
||||
# 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}'")
|
||||
|
||||
if errors:
|
||||
return json_response({"status": "error", "message": "Some files failed to upload", "details": errors}, status=500)
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
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'
|
||||
}
|
||||
)
|
||||
@@ -1,18 +1,17 @@
|
||||
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=install_requires,
|
||||
install_requires=[
|
||||
"aiohttp",
|
||||
"jinja2",
|
||||
],
|
||||
entry_points={
|
||||
"console_scripts": [
|
||||
"retoors=retoors.main:main",
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user