Compare commits
4 Commits
cd259b0b81
...
b374f7cd4f
| Author | SHA1 | Date | |
|---|---|---|---|
| b374f7cd4f | |||
| c281f1e9ea | |||
| 155992f196 | |||
| 71e1dec041 |
@ -1,4 +1,4 @@
|
||||
|
||||
import os
|
||||
import argparse
|
||||
from aiohttp import web
|
||||
import aiohttp_jinja2
|
||||
@ -73,8 +73,8 @@ def create_app():
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int, default=8080)
|
||||
parser.add_argument('--hostname', default='0.0.0.0')
|
||||
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)
|
||||
|
||||
@ -5,6 +5,16 @@ 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")
|
||||
@ -13,9 +23,6 @@ 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")
|
||||
@ -59,3 +66,12 @@ def setup_routes(app):
|
||||
app.router.add_delete("/api/users/{email}", delete_user, name="api_delete_user")
|
||||
app.router.add_get("/api/users/{email}", get_user_details, name="api_get_user_details")
|
||||
app.router.add_delete("/api/teams/{parent_email}", delete_team, name="api_delete_team")
|
||||
|
||||
app.router.add_get("/sharing/create", create_share_page, name="create_share_page")
|
||||
app.router.add_post("/api/sharing/create", create_share_handler, name="create_share")
|
||||
app.router.add_get("/share/{share_id}", view_share, name="view_share")
|
||||
app.router.add_get("/share/{share_id}/download", download_shared_file, name="download_share")
|
||||
app.router.add_get("/sharing/manage", manage_shares, name="manage_shares")
|
||||
app.router.add_get("/api/sharing/{share_id}", get_share_details, name="get_share_details")
|
||||
app.router.add_put("/api/sharing/{share_id}", update_share, name="update_share")
|
||||
app.router.add_get("/api/sharing/item/shares", get_item_shares, name="get_item_shares")
|
||||
|
||||
@ -8,6 +8,7 @@ import logging
|
||||
import hashlib
|
||||
import os
|
||||
from .storage_service import StorageService
|
||||
from .sharing_service import SharingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.DEBUG)
|
||||
@ -21,6 +22,7 @@ class FileService:
|
||||
self.base_dir = base_dir
|
||||
self.user_service = user_service
|
||||
self.storage = StorageService()
|
||||
self.sharing_service = SharingService()
|
||||
self.base_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.drives_dir = self.base_dir / "drives"
|
||||
self.drives_dir.mkdir(exist_ok=True)
|
||||
@ -63,6 +65,12 @@ class FileService:
|
||||
# Normalize path
|
||||
if path and not path.endswith('/'):
|
||||
path += '/'
|
||||
# Update last_accessed for the folder if path is not root
|
||||
if path:
|
||||
folder_path = path.rstrip('/')
|
||||
if folder_path in metadata and metadata[folder_path].get("type") == "dir":
|
||||
metadata[folder_path]["last_accessed"] = datetime.datetime.now().isoformat()
|
||||
await self._save_metadata(user_email, metadata)
|
||||
items = []
|
||||
seen = set()
|
||||
for item_path, item_meta in metadata.items():
|
||||
@ -91,6 +99,7 @@ class FileService:
|
||||
"type": "dir",
|
||||
"created_at": datetime.datetime.now().isoformat(),
|
||||
"modified_at": datetime.datetime.now().isoformat(),
|
||||
"last_accessed": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
await self._save_metadata(user_email, metadata)
|
||||
logger.info(f"create_folder: Folder created: {folder_path}")
|
||||
@ -115,6 +124,7 @@ class FileService:
|
||||
"blob_location": {"drive": drive, "path": f"{dir1}/{dir2}/{dir3}/{hash}"},
|
||||
"created_at": datetime.datetime.now().isoformat(),
|
||||
"modified_at": datetime.datetime.now().isoformat(),
|
||||
"last_accessed": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
await self._save_metadata(user_email, metadata)
|
||||
logger.info(f"upload_file: File uploaded to drive {drive}: {file_path}")
|
||||
@ -127,6 +137,9 @@ class FileService:
|
||||
logger.warning(f"download_file: File not found in metadata: {file_path}")
|
||||
return None
|
||||
item_meta = metadata[file_path]
|
||||
# Update last_accessed
|
||||
item_meta["last_accessed"] = datetime.datetime.now().isoformat()
|
||||
await self._save_metadata(user_email, metadata)
|
||||
blob_loc = item_meta["blob_location"]
|
||||
blob_path = self.drives_dir / blob_loc["drive"] / blob_loc["path"]
|
||||
if not blob_path.exists():
|
||||
@ -144,6 +157,9 @@ class FileService:
|
||||
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():
|
||||
@ -168,6 +184,9 @@ class FileService:
|
||||
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():
|
||||
@ -208,77 +227,92 @@ class FileService:
|
||||
logger.info(f"delete_item: Item deleted: {item_path}")
|
||||
return True
|
||||
|
||||
async def generate_share_link(self, user_email: str, item_path: str) -> str | None:
|
||||
"""Generates a shareable link for a file or folder."""
|
||||
async def generate_share_link(
|
||||
self,
|
||||
user_email: str,
|
||||
item_path: str,
|
||||
permission: str = "view",
|
||||
scope: str = "public",
|
||||
password: str = None,
|
||||
expiration_days: int = None,
|
||||
disable_download: bool = False,
|
||||
recipient_emails: list = None
|
||||
) -> str | None:
|
||||
|
||||
logger.debug(f"generate_share_link: Generating link for user '{user_email}', item '{item_path}'")
|
||||
metadata = await self._load_metadata(user_email)
|
||||
if item_path not in metadata:
|
||||
logger.warning(f"generate_share_link: Item does not exist: {item_path}")
|
||||
return None
|
||||
user = await self.user_service.get_user_by_email(user_email)
|
||||
if not user:
|
||||
logger.warning(f"generate_share_link: User not found: {user_email}")
|
||||
return None
|
||||
|
||||
share_id = str(uuid.uuid4())
|
||||
if "shared_items" not in user:
|
||||
user["shared_items"] = {}
|
||||
user["shared_items"][share_id] = {
|
||||
"user_email": user_email,
|
||||
"item_path": item_path,
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"expires_at": (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=7)).isoformat(), # 7-day expiry
|
||||
}
|
||||
await self.user_service.update_user(user_email, shared_items=user["shared_items"])
|
||||
share_id = await self.sharing_service.create_share(
|
||||
owner_email=user_email,
|
||||
item_path=item_path,
|
||||
permission=permission,
|
||||
scope=scope,
|
||||
password=password,
|
||||
expiration_days=expiration_days,
|
||||
disable_download=disable_download,
|
||||
recipient_emails=recipient_emails
|
||||
)
|
||||
|
||||
logger.info(f"generate_share_link: Share link generated with ID: {share_id} for item: {item_path}")
|
||||
return share_id
|
||||
|
||||
async def get_shared_item(self, share_id: str) -> dict | None:
|
||||
"""Retrieves information about a shared item."""
|
||||
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
|
||||
async def get_shared_item(self, share_id: str, password: str = None, accessor_email: str = None) -> dict | None:
|
||||
|
||||
logger.debug(f"get_shared_item: Retrieving shared item with ID: {share_id}")
|
||||
|
||||
share = await self.sharing_service.get_share(share_id)
|
||||
if not share:
|
||||
logger.warning(f"get_shared_item: No valid shared item found for ID: {share_id}")
|
||||
return None
|
||||
|
||||
if not await self.sharing_service.verify_share_access(share_id, password, accessor_email):
|
||||
logger.warning(f"get_shared_item: Access denied for share {share_id}")
|
||||
return None
|
||||
|
||||
await self.sharing_service.record_share_access(share_id, accessor_email)
|
||||
|
||||
logger.info(f"get_shared_item: Found valid shared item for ID: {share_id}")
|
||||
return share
|
||||
|
||||
async def get_shared_file_content(self, share_id: str, password: str = None, accessor_email: str = None, requested_file_path: str = None) -> tuple[bytes, str] | None:
|
||||
|
||||
async def get_shared_file_content(self, share_id: str, requested_file_path: str | None = None) -> tuple[bytes, str] | None:
|
||||
"""Retrieves the content of a shared file."""
|
||||
logger.debug(f"get_shared_file_content: Retrieving content for shared file with ID: {share_id}, requested_file_path: {requested_file_path}")
|
||||
shared_item = await self.get_shared_item(share_id)
|
||||
|
||||
shared_item = await self.get_shared_item(share_id, password, accessor_email)
|
||||
if not shared_item:
|
||||
return None
|
||||
|
||||
user_email = shared_item["user_email"]
|
||||
item_path = shared_item["item_path"] # This is the path of the originally shared item (file or folder)
|
||||
if shared_item.get("disable_download", False):
|
||||
logger.warning(f"get_shared_file_content: Download disabled for share {share_id}")
|
||||
return None
|
||||
|
||||
user_email = shared_item["owner_email"]
|
||||
item_path = shared_item["item_path"]
|
||||
|
||||
target_path = item_path
|
||||
if requested_file_path:
|
||||
target_path = requested_file_path
|
||||
# Security check: Ensure the requested file is actually within the shared item's directory
|
||||
if not target_path.startswith(item_path + '/'):
|
||||
if not target_path.startswith(item_path + '/') and target_path != item_path:
|
||||
logger.warning(f"get_shared_file_content: Requested file path '{requested_file_path}' is not within shared item path '{item_path}' for share_id: {share_id}")
|
||||
return None
|
||||
|
||||
return await self.download_file(user_email, target_path)
|
||||
|
||||
async def get_shared_folder_content(self, share_id: str) -> list | None:
|
||||
"""Retrieves the content of a shared folder."""
|
||||
async def get_shared_folder_content(self, share_id: str, password: str = None, accessor_email: str = None) -> list | None:
|
||||
|
||||
logger.debug(f"get_shared_folder_content: Retrieving content for shared folder with ID: {share_id}")
|
||||
shared_item = await self.get_shared_item(share_id)
|
||||
|
||||
shared_item = await self.get_shared_item(share_id, password, accessor_email)
|
||||
if not shared_item:
|
||||
return None
|
||||
|
||||
user_email = shared_item["user_email"]
|
||||
user_email = shared_item["owner_email"]
|
||||
item_path = shared_item["item_path"]
|
||||
metadata = await self._load_metadata(user_email)
|
||||
|
||||
if item_path not in metadata or metadata[item_path]["type"] != "dir":
|
||||
logger.warning(f"get_shared_folder_content: Shared item is not a directory: {item_path}")
|
||||
return None
|
||||
@ -303,6 +337,7 @@ class FileService:
|
||||
"type": "dir",
|
||||
"created_at": datetime.datetime.now().isoformat(),
|
||||
"modified_at": datetime.datetime.now().isoformat(),
|
||||
"last_accessed": datetime.datetime.now().isoformat(),
|
||||
}
|
||||
migrated_count += 1
|
||||
for file_name in files:
|
||||
@ -330,9 +365,28 @@ class FileService:
|
||||
"blob_location": {"drive": drive, "path": f"{dir1}/{dir2}/{dir3}/{hash}"},
|
||||
"created_at": datetime.datetime.fromtimestamp(full_file_path.stat().st_ctime).isoformat(),
|
||||
"modified_at": datetime.datetime.fromtimestamp(full_file_path.stat().st_mtime).isoformat(),
|
||||
"last_accessed": datetime.datetime.fromtimestamp(full_file_path.stat().st_atime).isoformat(),
|
||||
}
|
||||
migrated_count += 1
|
||||
await self._save_metadata(user_email, metadata)
|
||||
logger.info(f"Migrated {migrated_count} items for {user_email}")
|
||||
# Optionally remove old dir
|
||||
# shutil.rmtree(old_user_dir)
|
||||
|
||||
async def get_recent_files(self, user_email: str, limit: int = 50) -> list:
|
||||
"""Gets the most recently accessed files and folders for the user."""
|
||||
metadata = await self._load_metadata(user_email)
|
||||
items = []
|
||||
for path, meta in metadata.items():
|
||||
if meta.get("type") in ("file", "dir"):
|
||||
last_accessed = meta.get("last_accessed", meta.get("modified_at", ""))
|
||||
items.append({
|
||||
"path": path,
|
||||
"name": Path(path).name,
|
||||
"is_dir": meta["type"] == "dir",
|
||||
"size": meta.get("size", 0) if meta["type"] == "file" else 0,
|
||||
"last_accessed": last_accessed,
|
||||
})
|
||||
# Sort by last_accessed descending
|
||||
items.sort(key=lambda x: x["last_accessed"], reverse=True)
|
||||
return items[:limit]
|
||||
|
||||
317
retoors/services/sharing_service.py
Normal file
317
retoors/services/sharing_service.py
Normal file
@ -0,0 +1,317 @@
|
||||
import uuid
|
||||
import datetime
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Dict, List, Optional, Any
|
||||
from .storage_service import StorageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class SharingService:
|
||||
|
||||
PERMISSION_VIEW = "view"
|
||||
PERMISSION_EDIT = "edit"
|
||||
PERMISSION_COMMENT = "comment"
|
||||
|
||||
SCOPE_PUBLIC = "public"
|
||||
SCOPE_PRIVATE = "private"
|
||||
SCOPE_ACCOUNT_BASED = "account_based"
|
||||
|
||||
def __init__(self):
|
||||
self.storage = StorageService()
|
||||
|
||||
async def _load_shares(self, user_email: str) -> Dict:
|
||||
shares = await self.storage.load(user_email, "shares")
|
||||
return shares if shares else {}
|
||||
|
||||
async def _save_shares(self, user_email: str, shares: Dict):
|
||||
await self.storage.save(user_email, "shares", shares)
|
||||
|
||||
async def _load_share_recipients(self, share_id: str) -> Dict:
|
||||
recipients = await self.storage.load("global_shares", f"recipients_{share_id}")
|
||||
return recipients if recipients else {}
|
||||
|
||||
async def _save_share_recipients(self, share_id: str, recipients: Dict):
|
||||
await self.storage.save("global_shares", f"recipients_{share_id}", recipients)
|
||||
|
||||
async def _load_all_shares(self) -> Dict:
|
||||
all_shares = await self.storage.load("global_shares", "all_shares_index")
|
||||
return all_shares if all_shares else {}
|
||||
|
||||
async def _save_all_shares(self, all_shares: Dict):
|
||||
await self.storage.save("global_shares", "all_shares_index", all_shares)
|
||||
|
||||
def _hash_password(self, password: str) -> str:
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
|
||||
async def create_share(
|
||||
self,
|
||||
owner_email: str,
|
||||
item_path: str,
|
||||
permission: str = PERMISSION_VIEW,
|
||||
scope: str = SCOPE_PUBLIC,
|
||||
password: Optional[str] = None,
|
||||
expiration_days: Optional[int] = None,
|
||||
disable_download: bool = False,
|
||||
recipient_emails: Optional[List[str]] = None
|
||||
) -> str:
|
||||
|
||||
share_id = str(uuid.uuid4())
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
expires_at = None
|
||||
if expiration_days:
|
||||
expires_at = (now + datetime.timedelta(days=expiration_days)).isoformat()
|
||||
|
||||
password_hash = None
|
||||
if password:
|
||||
password_hash = self._hash_password(password)
|
||||
|
||||
share_data = {
|
||||
"share_id": share_id,
|
||||
"owner_email": owner_email,
|
||||
"item_path": item_path,
|
||||
"permission": permission,
|
||||
"scope": scope,
|
||||
"password_hash": password_hash,
|
||||
"created_at": now.isoformat(),
|
||||
"expires_at": expires_at,
|
||||
"disable_download": disable_download,
|
||||
"active": True,
|
||||
"access_count": 0,
|
||||
"last_accessed": None
|
||||
}
|
||||
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
user_shares[share_id] = share_data
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
all_shares = await self._load_all_shares()
|
||||
all_shares[share_id] = {
|
||||
"owner_email": owner_email,
|
||||
"item_path": item_path,
|
||||
"created_at": now.isoformat()
|
||||
}
|
||||
await self._save_all_shares(all_shares)
|
||||
|
||||
if recipient_emails and scope in [self.SCOPE_PRIVATE, self.SCOPE_ACCOUNT_BASED]:
|
||||
recipients = {}
|
||||
for email in recipient_emails:
|
||||
recipients[email] = {
|
||||
"email": email,
|
||||
"permission": permission,
|
||||
"invited_at": now.isoformat(),
|
||||
"accessed": False
|
||||
}
|
||||
await self._save_share_recipients(share_id, recipients)
|
||||
|
||||
logger.info(f"Created share {share_id} for {item_path} by {owner_email}")
|
||||
return share_id
|
||||
|
||||
async def get_share(self, share_id: str) -> Optional[Dict]:
|
||||
all_shares = await self._load_all_shares()
|
||||
if share_id not in all_shares:
|
||||
return None
|
||||
|
||||
owner_email = all_shares[share_id]["owner_email"]
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return None
|
||||
|
||||
share = user_shares[share_id]
|
||||
|
||||
if not share.get("active", True):
|
||||
logger.warning(f"Share {share_id} is deactivated")
|
||||
return None
|
||||
|
||||
if share.get("expires_at"):
|
||||
expiry_time = datetime.datetime.fromisoformat(share["expires_at"])
|
||||
if expiry_time <= datetime.datetime.now(datetime.timezone.utc):
|
||||
logger.warning(f"Share {share_id} has expired")
|
||||
return None
|
||||
|
||||
return share
|
||||
|
||||
async def verify_share_access(
|
||||
self,
|
||||
share_id: str,
|
||||
password: Optional[str] = None,
|
||||
accessor_email: Optional[str] = None
|
||||
) -> bool:
|
||||
|
||||
share = await self.get_share(share_id)
|
||||
if not share:
|
||||
return False
|
||||
|
||||
if share.get("password_hash") and password:
|
||||
if self._hash_password(password) != share["password_hash"]:
|
||||
logger.warning(f"Invalid password for share {share_id}")
|
||||
return False
|
||||
elif share.get("password_hash") and not password:
|
||||
return False
|
||||
|
||||
if share["scope"] == self.SCOPE_PRIVATE and accessor_email:
|
||||
recipients = await self._load_share_recipients(share_id)
|
||||
if accessor_email not in recipients:
|
||||
logger.warning(f"Email {accessor_email} not in recipients for share {share_id}")
|
||||
return False
|
||||
|
||||
if share["scope"] == self.SCOPE_ACCOUNT_BASED and not accessor_email:
|
||||
logger.warning(f"Account-based share {share_id} requires authenticated user")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def record_share_access(self, share_id: str, accessor_email: Optional[str] = None):
|
||||
all_shares = await self._load_all_shares()
|
||||
if share_id not in all_shares:
|
||||
return
|
||||
|
||||
owner_email = all_shares[share_id]["owner_email"]
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id in user_shares:
|
||||
user_shares[share_id]["access_count"] = user_shares[share_id].get("access_count", 0) + 1
|
||||
user_shares[share_id]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
if accessor_email:
|
||||
recipients = await self._load_share_recipients(share_id)
|
||||
if accessor_email in recipients:
|
||||
recipients[accessor_email]["accessed"] = True
|
||||
recipients[accessor_email]["last_accessed"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
await self._save_share_recipients(share_id, recipients)
|
||||
|
||||
async def deactivate_share(self, owner_email: str, share_id: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
user_shares[share_id]["active"] = False
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
logger.info(f"Deactivated share {share_id}")
|
||||
return True
|
||||
|
||||
async def reactivate_share(self, owner_email: str, share_id: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
user_shares[share_id]["active"] = True
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
logger.info(f"Reactivated share {share_id}")
|
||||
return True
|
||||
|
||||
async def delete_share(self, owner_email: str, share_id: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
del user_shares[share_id]
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
all_shares = await self._load_all_shares()
|
||||
if share_id in all_shares:
|
||||
del all_shares[share_id]
|
||||
await self._save_all_shares(all_shares)
|
||||
|
||||
logger.info(f"Deleted share {share_id}")
|
||||
return True
|
||||
|
||||
async def update_share_permission(self, owner_email: str, share_id: str, permission: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
user_shares[share_id]["permission"] = permission
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
logger.info(f"Updated permission for share {share_id} to {permission}")
|
||||
return True
|
||||
|
||||
async def update_share_expiration(self, owner_email: str, share_id: str, expiration_days: Optional[int]) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
if expiration_days:
|
||||
expires_at = (datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(days=expiration_days)).isoformat()
|
||||
user_shares[share_id]["expires_at"] = expires_at
|
||||
else:
|
||||
user_shares[share_id]["expires_at"] = None
|
||||
|
||||
await self._save_shares(owner_email, user_shares)
|
||||
|
||||
logger.info(f"Updated expiration for share {share_id}")
|
||||
return True
|
||||
|
||||
async def add_share_recipient(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
recipients = await self._load_share_recipients(share_id)
|
||||
recipients[recipient_email] = {
|
||||
"email": recipient_email,
|
||||
"permission": permission,
|
||||
"invited_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"accessed": False
|
||||
}
|
||||
await self._save_share_recipients(share_id, recipients)
|
||||
|
||||
logger.info(f"Added recipient {recipient_email} to share {share_id}")
|
||||
return True
|
||||
|
||||
async def remove_share_recipient(self, owner_email: str, share_id: str, recipient_email: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
recipients = await self._load_share_recipients(share_id)
|
||||
if recipient_email in recipients:
|
||||
del recipients[recipient_email]
|
||||
await self._save_share_recipients(share_id, recipients)
|
||||
logger.info(f"Removed recipient {recipient_email} from share {share_id}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def update_recipient_permission(self, owner_email: str, share_id: str, recipient_email: str, permission: str) -> bool:
|
||||
user_shares = await self._load_shares(owner_email)
|
||||
|
||||
if share_id not in user_shares:
|
||||
return False
|
||||
|
||||
recipients = await self._load_share_recipients(share_id)
|
||||
if recipient_email in recipients:
|
||||
recipients[recipient_email]["permission"] = permission
|
||||
await self._save_share_recipients(share_id, recipients)
|
||||
logger.info(f"Updated permission for {recipient_email} in share {share_id} to {permission}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def get_share_recipients(self, share_id: str) -> Dict:
|
||||
return await self._load_share_recipients(share_id)
|
||||
|
||||
async def list_user_shares(self, user_email: str) -> List[Dict]:
|
||||
user_shares = await self._load_shares(user_email)
|
||||
return list(user_shares.values())
|
||||
|
||||
async def get_shares_for_item(self, user_email: str, item_path: str) -> List[Dict]:
|
||||
user_shares = await self._load_shares(user_email)
|
||||
item_shares = [
|
||||
share for share in user_shares.values()
|
||||
if share["item_path"] == item_path
|
||||
]
|
||||
return item_shares
|
||||
@ -211,3 +211,48 @@ class UserService:
|
||||
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", [])
|
||||
|
||||
@ -1,24 +1,32 @@
|
||||
:root {
|
||||
--primary-color: #0066FF; /* Vibrant blue */
|
||||
--accent-color: #00D4FF; /* Bright cyan accent */
|
||||
--secondary-color: #F7FAFC; /* Very light blue-grey */
|
||||
--background-color: #FFFFFF; /* Pure white background */
|
||||
--text-color: #1A202C; /* Dark blue-grey */
|
||||
--light-text-color: #718096; /* Medium grey for descriptions */
|
||||
--border-color: #E2E8F0; /* Light grey border */
|
||||
--card-background: #FFFFFF; /* White for cards */
|
||||
--shadow-color: rgba(0, 0, 0, 0.05); /* Very subtle shadow */
|
||||
--dutch-red: #AE1C28; /* Dutch flag red */
|
||||
--dutch-white: #FFFFFF; /* Dutch flag white */
|
||||
--dutch-blue: #21468B; /* Dutch flag blue */
|
||||
--dutch-red-dark: #8B1621; /* Darker red for hover */
|
||||
--dutch-blue-dark: #1A3766; /* Darker blue for hover */
|
||||
|
||||
--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(--primary-color);
|
||||
--btn-primary-text: #FFFFFF;
|
||||
--btn-primary-hover-bg: #0052CC; /* Darker blue */
|
||||
--btn-secondary-bg: #EDF2F7; /* Light grey */
|
||||
--btn-secondary-text: var(--text-color);
|
||||
--btn-secondary-hover-bg: #E2E8F0; /* Darker grey */
|
||||
--btn-outline-border: var(--primary-color);
|
||||
--btn-outline-text: var(--primary-color);
|
||||
--btn-outline-hover-bg: rgba(0, 102, 255, 0.05); /* Very light blue hover */
|
||||
--btn-primary-bg: var(--dutch-blue);
|
||||
--btn-primary-text: var(--dutch-white);
|
||||
--btn-primary-hover-bg: var(--dutch-blue-dark);
|
||||
--btn-secondary-bg: var(--dutch-red);
|
||||
--btn-secondary-text: var(--dutch-white);
|
||||
--btn-secondary-hover-bg: var(--dutch-red-dark);
|
||||
--btn-outline-border: var(--dutch-blue);
|
||||
--btn-outline-text: var(--dutch-blue);
|
||||
--btn-outline-hover-bg: rgba(33, 70, 139, 0.05);
|
||||
}
|
||||
|
||||
html, body {
|
||||
@ -37,7 +45,7 @@ html, body {
|
||||
|
||||
/* General typography */
|
||||
h1 {
|
||||
font-size: 3.5rem;
|
||||
font-size: clamp(1.75rem, 5vw, 3.5rem);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1.5rem;
|
||||
color: var(--text-color);
|
||||
@ -46,7 +54,7 @@ h1 {
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 2.5rem;
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text-color);
|
||||
@ -55,7 +63,7 @@ h2 {
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-size: clamp(1.125rem, 3vw, 1.5rem);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--text-color);
|
||||
@ -65,30 +73,30 @@ h3 {
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--light-text-color);
|
||||
font-size: 1.125rem;
|
||||
font-size: clamp(0.875rem, 2vw, 1.125rem);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* Card-like styling for sections */
|
||||
.card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 12px; /* Slightly more rounded corners */
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1); /* Softer, more prominent shadow */
|
||||
padding: 3rem; /* Increased padding */
|
||||
margin-bottom: 35px; /* Increased margin */
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
|
||||
padding: clamp(1rem, 4vw, 3rem);
|
||||
margin-bottom: clamp(20px, 3vw, 35px);
|
||||
}
|
||||
|
||||
.container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column; /* Allow content to stack vertically */
|
||||
justify-content: flex-start; /* Align content to the top */
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
padding: clamp(10px, 2vw, 20px);
|
||||
box-sizing: border-box;
|
||||
width: 100%; /* Ensure container takes full width */
|
||||
max-width: 1200px; /* Max width for overall content */
|
||||
margin: 0 auto; /* Center the container */
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.retoors-container {
|
||||
@ -125,8 +133,8 @@ p {
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
box-shadow: 0 1px 6px rgba(32,33,36,.28);
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 1px 6px rgba(174, 28, 40, 0.25);
|
||||
border-color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.search-buttons {
|
||||
@ -136,10 +144,10 @@ p {
|
||||
}
|
||||
|
||||
.retoors-button {
|
||||
background-color: var(--btn-secondary-bg);
|
||||
border: 1px solid var(--btn-secondary-bg);
|
||||
background-color: #F0F0F0;
|
||||
border: 1px solid #D0D0D0;
|
||||
border-radius: 4px;
|
||||
color: var(--btn-secondary-text);
|
||||
color: var(--text-color);
|
||||
font-family: 'Roboto', sans-serif;
|
||||
font-size: 15px;
|
||||
padding: 10px 20px;
|
||||
@ -150,18 +158,23 @@ p {
|
||||
}
|
||||
|
||||
.retoors-button:hover {
|
||||
background-color: var(--btn-secondary-hover-bg);
|
||||
border-color: var(--btn-secondary-hover-bg);
|
||||
background-color: #E0E0E0;
|
||||
border-color: #B0B0B0;
|
||||
}
|
||||
|
||||
/* Header and Navigation */
|
||||
.site-header {
|
||||
background-color: #FFFFFF;
|
||||
background: linear-gradient(to bottom, var(--dutch-red) 0%, var(--dutch-red) 33.33%, var(--dutch-white) 33.33%, var(--dutch-white) 66.66%, var(--dutch-blue) 66.66%, var(--dutch-blue) 100%);
|
||||
background-size: 100% 6px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: top;
|
||||
background-color: var(--dutch-white);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
@ -217,7 +230,7 @@ p {
|
||||
}
|
||||
|
||||
.nav-menu a:hover {
|
||||
color: var(--primary-color);
|
||||
color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
@ -238,7 +251,7 @@ p {
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--primary-color);
|
||||
color: var(--dutch-red);
|
||||
}
|
||||
|
||||
/* Main content area */
|
||||
@ -344,7 +357,7 @@ main {
|
||||
background-color: var(--btn-primary-hover-bg);
|
||||
border-color: var(--btn-primary-hover-bg);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 102, 255, 0.2);
|
||||
box-shadow: 0 4px 12px rgba(33, 70, 139, 0.3);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
@ -366,14 +379,27 @@ main {
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
background-color: var(--red-accent);
|
||||
color: white;
|
||||
border-color: #dc3545;
|
||||
border-color: var(--red-accent);
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
background-color: var(--red-hover);
|
||||
border-color: var(--red-hover);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background-color: var(--dutch-red);
|
||||
color: var(--dutch-white);
|
||||
border-color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background-color: var(--dutch-red-dark);
|
||||
border-color: var(--dutch-red-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(174, 28, 40, 0.3);
|
||||
}
|
||||
|
||||
.error {
|
||||
@ -442,15 +468,15 @@ main {
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
gap: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-menu a {
|
||||
font-size: 0.85rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.nav-actions {
|
||||
@ -464,5 +490,67 @@ main {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,8 @@
|
||||
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 {
|
||||
@ -53,8 +55,9 @@
|
||||
|
||||
.sidebar-menu ul li a:hover,
|
||||
.sidebar-menu ul li a.active {
|
||||
background-color: var(--accent-color);
|
||||
color: white;
|
||||
background-color: var(--dutch-blue);
|
||||
color: var(--dutch-white);
|
||||
border-left: 3px solid var(--dutch-red);
|
||||
}
|
||||
|
||||
.sidebar-menu ul li a img.icon {
|
||||
@ -164,8 +167,8 @@
|
||||
}
|
||||
|
||||
.file-search-bar:focus {
|
||||
border-color: var(--accent-color);
|
||||
box-shadow: 0 0 0 2px rgba(0, 188, 212, 0.2);
|
||||
border-color: var(--dutch-red);
|
||||
box-shadow: 0 0 0 2px rgba(174, 28, 40, 0.2);
|
||||
}
|
||||
|
||||
.file-list-table {
|
||||
@ -297,11 +300,54 @@
|
||||
|
||||
.file-list-table th,
|
||||
.file-list-table td {
|
||||
padding: 10px 12px;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.file-list-table td {
|
||||
max-width: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.dashboard-layout {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.dashboard-sidebar {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.dashboard-content-header h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.dashboard-actions .btn-primary,
|
||||
.dashboard-actions .btn-outline {
|
||||
padding: 0.5rem 0.8rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.file-list-table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.file-list-table th,
|
||||
.file-list-table td {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.file-list-table td {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.file-search-bar {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,9 @@ footer {
|
||||
background-color: var(--card-background);
|
||||
color: var(--light-text-color);
|
||||
padding: 2rem 2rem 1rem 2rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
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);
|
||||
}
|
||||
@ -44,7 +46,7 @@ footer {
|
||||
}
|
||||
|
||||
.footer-section ul li a:hover {
|
||||
color: var(--accent-color);
|
||||
color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.footer-bottom {
|
||||
|
||||
@ -1 +1,106 @@
|
||||
/* Styles for Form Pages */
|
||||
|
||||
.form-page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.form-page-container h1 {
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-page-container .subtitle {
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
}
|
||||
|
||||
.form-page-container .form-container {
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
}
|
||||
|
||||
.login-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 25px;
|
||||
font-size: 0.95rem;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
|
||||
.login-link a {
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
color: var(--dutch-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #2E7D32;
|
||||
background-color: #E8F5E9;
|
||||
border: 1px solid #81C784;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.form-page-container {
|
||||
padding: 30px 15px 40px;
|
||||
}
|
||||
.form-page-container h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.form-page-container .subtitle {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-page-container .form-container {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.form-page-container {
|
||||
padding: 20px 10px 30px;
|
||||
}
|
||||
.form-page-container h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.form-page-container .subtitle {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-page-container .form-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-link {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.message {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
text-align: center;
|
||||
padding: 100px 20px 80px;
|
||||
background: linear-gradient(135deg, #F7FAFC 0%, #FFFFFF 100%);
|
||||
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 {
|
||||
@ -12,19 +15,19 @@
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 3.75rem;
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--light-text-color);
|
||||
max-width: 700px;
|
||||
margin: 0 auto 2.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@ -52,7 +55,7 @@
|
||||
|
||||
/* Features Section */
|
||||
.features-section {
|
||||
padding: 80px 20px;
|
||||
padding: clamp(30px, 8vw, 80px) clamp(10px, 3vw, 20px);
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
@ -74,8 +77,8 @@
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
|
||||
gap: 2.5rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: clamp(1.5rem, 3vw, 2.5rem);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@ -86,7 +89,7 @@
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
color: var(--primary-color);
|
||||
color: var(--dutch-red);
|
||||
margin-bottom: 1.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
@ -104,10 +107,141 @@
|
||||
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 {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
|
||||
.price-display {
|
||||
margin-bottom: 2.5rem;
|
||||
padding: clamp(1rem, 3vw, 2rem);
|
||||
background: linear-gradient(135deg, rgba(33, 70, 139, 0.05) 0%, rgba(174, 28, 40, 0.05) 100%);
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--dutch-blue);
|
||||
}
|
||||
|
||||
.price-amount {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: clamp(1.25rem, 4vw, 2rem);
|
||||
color: var(--text-color);
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-size: clamp(2rem, 8vw, 4rem);
|
||||
font-weight: 700;
|
||||
color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.price-period {
|
||||
font-size: clamp(0.875rem, 2.5vw, 1.25rem);
|
||||
color: var(--light-text-color);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.price-description {
|
||||
font-size: clamp(0.875rem, 2.5vw, 1.125rem);
|
||||
color: var(--dutch-blue);
|
||||
font-weight: 600;
|
||||
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: 80px 20px;
|
||||
background: linear-gradient(135deg, #F7FAFC 0%, #EDF2F7 100%);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -119,8 +253,8 @@
|
||||
|
||||
.use-cases-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
|
||||
gap: clamp(1.5rem, 3vw, 2rem);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
@ -135,7 +269,9 @@
|
||||
|
||||
.use-case-card:hover {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.12);
|
||||
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 {
|
||||
@ -159,10 +295,11 @@
|
||||
|
||||
/* CTA Section */
|
||||
.cta-section {
|
||||
padding: 100px 20px;
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #0052CC 100%);
|
||||
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: white;
|
||||
color: var(--dutch-white);
|
||||
border-top: 6px solid var(--dutch-red);
|
||||
}
|
||||
|
||||
.cta-content {
|
||||
@ -172,13 +309,13 @@
|
||||
|
||||
.cta-section h2 {
|
||||
font-size: 2.75rem;
|
||||
color: white;
|
||||
color: var(--dutch-white);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.cta-section p {
|
||||
font-size: 1.25rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
@ -186,17 +323,20 @@
|
||||
padding: 1rem 2.5rem;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
background-color: white;
|
||||
color: var(--primary-color);
|
||||
background-color: var(--dutch-white);
|
||||
color: var(--dutch-blue);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease, background-color 0.3s ease;
|
||||
border: 2px solid var(--dutch-red);
|
||||
}
|
||||
|
||||
.cta-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0 8px 20px rgba(174, 28, 40, 0.3);
|
||||
background-color: var(--dutch-red);
|
||||
color: var(--dutch-white);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@ -259,17 +399,135 @@
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.hero-section {
|
||||
padding: 40px 15px 30px;
|
||||
}
|
||||
|
||||
.hero-section h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.features-header h2,
|
||||
.use-cases-section h2,
|
||||
.cta-section h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.pricing-calculator {
|
||||
padding: 40px 15px;
|
||||
}
|
||||
|
||||
.pricing-calculator h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.calculator-subtitle {
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@ -4,31 +4,33 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 20px;
|
||||
justify-content: flex-start;
|
||||
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.login-page-container h1 {
|
||||
font-size: 2.5rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.login-page-container .subtitle {
|
||||
font-size: 1.1rem;
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 450px; /* Adjust as needed */
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
padding: 2.5rem;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
}
|
||||
|
||||
.forgot-password-link {
|
||||
@ -42,7 +44,7 @@
|
||||
}
|
||||
|
||||
.forgot-password-link:hover {
|
||||
color: var(--accent-color);
|
||||
color: var(--dutch-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@ -60,19 +62,46 @@
|
||||
}
|
||||
|
||||
.create-account-link a:hover {
|
||||
color: var(--accent-color);
|
||||
color: var(--dutch-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.login-page-container {
|
||||
padding: 30px 15px 40px;
|
||||
}
|
||||
.login-page-container h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.login-page-container .subtitle {
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-container {
|
||||
padding: 2rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.login-page-container {
|
||||
padding: 20px 10px 30px;
|
||||
}
|
||||
.login-page-container h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.login-page-container .subtitle {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.forgot-password-link {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.create-account-link {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -373,4 +373,59 @@
|
||||
flex-grow: unset;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.overview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
.quota-overview-card {
|
||||
padding: 10px;
|
||||
}
|
||||
.quota-overview-card h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.quota-overview-card .subtitle {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.donut-chart-container {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
}
|
||||
.donut-chart-container::before {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
}
|
||||
.donut-chart-text {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.order-form-card {
|
||||
padding: 10px;
|
||||
}
|
||||
.order-form-card h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.user-quotas-section {
|
||||
padding: 10px;
|
||||
}
|
||||
.user-quotas-header h2 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.user-quota-list {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
.user-quota-item {
|
||||
padding: 15px;
|
||||
}
|
||||
.user-quota-item .user-info h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.modal-content {
|
||||
padding: 15px;
|
||||
}
|
||||
.modal-content h3 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
}
|
||||
@ -1,176 +0,0 @@
|
||||
/* Styles for the Pricing Page (pricing.html) */
|
||||
|
||||
.pricing-hero {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.pricing-hero h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.pricing-hero p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.pricing-toggle {
|
||||
display: inline-flex;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.pricing-toggle .btn-toggle {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
background-color: var(--card-background);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.pricing-toggle .btn-toggle.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.pricing-toggle .btn-toggle:hover:not(.active) {
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
.pricing-tiers {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.pricing-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.pricing-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.pricing-card.featured {
|
||||
border: 2px solid var(--primary-color);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.pricing-card h3 {
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.pricing-card .price {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pricing-card .price span {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 400;
|
||||
color: var(--light-text-color);
|
||||
}
|
||||
|
||||
.pricing-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-bottom: 30px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pricing-card ul li {
|
||||
margin-bottom: 10px;
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pricing-card ul li::before {
|
||||
content: 'âś“'; /* Checkmark icon */
|
||||
color: var(--accent-color);
|
||||
margin-right: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.pricing-card .btn-primary {
|
||||
margin-top: auto; /* Push button to the bottom */
|
||||
width: 100%;
|
||||
padding: 12px 20px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.pricing-faq {
|
||||
max-width: 800px;
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.pricing-faq h2 {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.faq-item {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px var(--shadow-color);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.faq-item h3 {
|
||||
font-size: 1.3rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.faq-item p {
|
||||
font-size: 1rem;
|
||||
color: var(--light-text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.pricing-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.pricing-hero p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.pricing-tiers {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.pricing-card.featured {
|
||||
transform: none; /* Remove scale on small screens */
|
||||
}
|
||||
.pricing-faq h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@ -4,31 +4,33 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - 120px); /* Adjust based on header/footer height */
|
||||
padding: 20px;
|
||||
justify-content: flex-start;
|
||||
padding: clamp(30px, 5vh, 60px) clamp(15px, 3vw, 20px) clamp(40px, 6vh, 80px);
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.register-page-container h1 {
|
||||
font-size: 2.5rem;
|
||||
font-size: clamp(1.75rem, 4vw, 2.5rem);
|
||||
color: var(--text-color);
|
||||
margin-bottom: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.register-page-container .subtitle {
|
||||
font-size: 1.1rem;
|
||||
font-size: clamp(0.95rem, 2vw, 1.1rem);
|
||||
color: var(--light-text-color);
|
||||
margin-bottom: 30px;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-container {
|
||||
max-width: 450px; /* Adjust as needed */
|
||||
max-width: 450px;
|
||||
width: 100%;
|
||||
padding: 2.5rem;
|
||||
padding: clamp(1.5rem, 4vw, 2.5rem);
|
||||
box-sizing: border-box;
|
||||
margin-bottom: clamp(20px, 4vh, 30px);
|
||||
}
|
||||
|
||||
.terms-checkbox {
|
||||
@ -44,16 +46,16 @@
|
||||
margin-right: 10px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
accent-color: var(--accent-color); /* Style checkbox with accent color */
|
||||
accent-color: var(--dutch-red);
|
||||
}
|
||||
|
||||
.terms-checkbox a {
|
||||
color: var(--primary-color);
|
||||
color: var(--dutch-blue);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.terms-checkbox a:hover {
|
||||
color: var(--accent-color);
|
||||
color: var(--dutch-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@ -71,19 +73,46 @@
|
||||
}
|
||||
|
||||
.login-link a:hover {
|
||||
color: var(--accent-color);
|
||||
color: var(--dutch-red);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 600px) {
|
||||
.register-page-container {
|
||||
padding: 30px 15px 40px;
|
||||
}
|
||||
.register-page-container h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.register-page-container .subtitle {
|
||||
font-size: 1rem;
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.form-container {
|
||||
padding: 2rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.register-page-container {
|
||||
padding: 20px 10px 30px;
|
||||
}
|
||||
.register-page-container h1 {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.register-page-container .subtitle {
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
.form-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
.terms-checkbox {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.login-link {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
/* Styles for the Security Page (security.html) */
|
||||
|
||||
.security-hero {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.security-hero h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.security-hero p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--light-text-color);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.security-pillars {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.pillar-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.pillar-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.pillar-card img.icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.pillar-card h3 {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.pillar-card p {
|
||||
font-size: 1rem;
|
||||
color: var(--light-text-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.security-certifications {
|
||||
text-align: center;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.security-certifications h2 {
|
||||
font-size: 2.5rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.cert-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 40px;
|
||||
}
|
||||
|
||||
.cert-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.cert-item img.cert-logo {
|
||||
height: 60px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.cert-item p {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.security-cta {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.security-cta h2 {
|
||||
font-size: 2rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.security-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.security-hero p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.security-certifications h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.security-cta h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.security-hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.pillar-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.cert-grid {
|
||||
gap: 20px;
|
||||
}
|
||||
.cert-item img.cert-logo {
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
/* Styles for the Solutions Page (solutions.html) */
|
||||
|
||||
.solutions-hero {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
.solutions-hero h1 {
|
||||
font-size: 3rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.solutions-hero p {
|
||||
font-size: 1.2rem;
|
||||
color: var(--light-text-color);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.solution-sections {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.solution-card {
|
||||
background-color: var(--card-background);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px var(--shadow-color);
|
||||
padding: 30px;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.3s ease-in-out, box-shadow 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.solution-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.solution-card img.icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin-bottom: 20px;
|
||||
align-self: center; /* Center the icon */
|
||||
}
|
||||
|
||||
.solution-card h2 {
|
||||
font-size: 1.8rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.solution-card p {
|
||||
font-size: 1rem;
|
||||
color: var(--light-text-color);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.solution-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.solution-card ul li {
|
||||
margin-bottom: 8px;
|
||||
color: var(--text-color);
|
||||
font-size: 0.95rem;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.solution-card ul li::before {
|
||||
content: '•'; /* Bullet point */
|
||||
color: var(--accent-color);
|
||||
margin-right: 10px;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.solution-card .btn-primary {
|
||||
margin-top: auto; /* Push button to the bottom */
|
||||
align-self: center; /* Center the button */
|
||||
padding: 10px 20px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.solutions-cta {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
background-color: var(--background-color);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.solutions-cta h2 {
|
||||
font-size: 2rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.solutions-hero h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
.solutions-hero p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.solution-sections {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.solutions-cta h2 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.solutions-hero h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.solution-card {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
@ -169,7 +169,53 @@
|
||||
.support-hero h1, .support-categories h2, .contact-options h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.support-hero p {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.category-card, .contact-card {
|
||||
padding: 15px;
|
||||
}
|
||||
.search-support .search-input {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.support-hero {
|
||||
padding: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.support-hero h1, .support-categories h2, .contact-options h2 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.support-hero p {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.search-support {
|
||||
max-width: 100%;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-support .search-input {
|
||||
padding: 8px 12px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.support-categories {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.category-grid, .contact-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
.category-card, .contact-card {
|
||||
padding: 12px;
|
||||
}
|
||||
.category-card h3, .contact-card h3 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.category-card p, .contact-card p {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.contact-options {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
}
|
||||
@ -2,18 +2,18 @@
|
||||
|
||||
.use-cases-hero {
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
margin-bottom: 60px;
|
||||
padding: clamp(20px, 5vw, 40px) clamp(10px, 3vw, 20px);
|
||||
margin-bottom: clamp(30px, 6vw, 60px);
|
||||
}
|
||||
|
||||
.use-cases-hero h1 {
|
||||
font-size: 3rem;
|
||||
font-size: clamp(1.75rem, 5vw, 3rem);
|
||||
color: var(--text-color);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.use-cases-hero p {
|
||||
font-size: 1.2rem;
|
||||
font-size: clamp(0.95rem, 2.5vw, 1.2rem);
|
||||
color: var(--light-text-color);
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
@ -21,11 +21,11 @@
|
||||
|
||||
.use-case-scenarios {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 30px;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
|
||||
gap: clamp(20px, 3vw, 30px);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto 60px auto;
|
||||
padding: 0 20px;
|
||||
margin: 0 auto clamp(30px, 6vw, 60px) auto;
|
||||
padding: 0 clamp(10px, 3vw, 20px);
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
@ -127,9 +127,52 @@
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.use-cases-hero h1 {
|
||||
font-size: 2rem;
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
.use-cases-hero p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.scenario-card {
|
||||
padding: 20px;
|
||||
}
|
||||
.scenario-card h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.use-cases-cta h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.use-cases-hero {
|
||||
padding: 15px 10px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.use-cases-hero h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.use-cases-hero p {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.use-case-scenarios {
|
||||
gap: 20px;
|
||||
padding: 0 10px;
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
.scenario-card {
|
||||
padding: 15px;
|
||||
}
|
||||
.scenario-card h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.scenario-card p {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.use-cases-cta {
|
||||
padding: 20px 10px;
|
||||
}
|
||||
.use-cases-cta h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
@ -88,9 +88,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
</div>
|
||||
<div class="quota-actions">
|
||||
<button class="btn-outline edit-quota-btn" data-email="${user.email}" data-quota="${user.storage_quota_gb}">Edit Quota</button>
|
||||
<button class="btn-outline delete-user-btn" data-email="${user.email}">Delete User</button>
|
||||
<button class="btn-outline delete-user-btn" data-email="${user.email}">🗑️</button>
|
||||
<button class="btn-outline view-details-btn" data-email="${user.email}">View Details</button>
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">Delete Team</button>` : ''}
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">🗑️</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
userQuotaList.appendChild(quotaItem);
|
||||
|
||||
79
retoors/static/js/components/pricing.js
Normal file
79
retoors/static/js/components/pricing.js
Normal file
@ -0,0 +1,79 @@
|
||||
class PricingCalculator {
|
||||
constructor() {
|
||||
this.slider = document.getElementById('storageSlider');
|
||||
this.storageValue = document.getElementById('storageValue');
|
||||
this.priceValue = document.getElementById('priceValue');
|
||||
this.planDescription = document.getElementById('planDescription');
|
||||
|
||||
this.pricingTiers = [
|
||||
{ maxGB: 10, price: 0, name: 'Free Plan' },
|
||||
{ maxGB: 100, price: 9, name: 'Personal Plan' },
|
||||
{ maxGB: 500, price: 29, name: 'Professional Plan' },
|
||||
{ maxGB: 1000, price: 49, name: 'Business Plan' },
|
||||
{ maxGB: 2000, price: 99, name: 'Enterprise Plan' }
|
||||
];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
if (!this.slider) return;
|
||||
|
||||
this.slider.addEventListener('input', () => this.updatePrice());
|
||||
this.updatePrice();
|
||||
}
|
||||
|
||||
calculatePrice(storageGB) {
|
||||
for (let tier of this.pricingTiers) {
|
||||
if (storageGB <= tier.maxGB) {
|
||||
return {
|
||||
price: tier.price,
|
||||
plan: tier.name
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
price: 99,
|
||||
plan: 'Enterprise Plan'
|
||||
};
|
||||
}
|
||||
|
||||
formatStorage(value) {
|
||||
if (value >= 1000) {
|
||||
return `${(value / 1000).toFixed(1)} TB`;
|
||||
}
|
||||
return `${value} GB`;
|
||||
}
|
||||
|
||||
updatePrice() {
|
||||
const storage = parseInt(this.slider.value);
|
||||
const pricing = this.calculatePrice(storage);
|
||||
|
||||
if (storage >= 1000) {
|
||||
this.storageValue.textContent = (storage / 1000).toFixed(1);
|
||||
this.storageValue.nextElementSibling.textContent = 'TB';
|
||||
} else {
|
||||
this.storageValue.textContent = storage;
|
||||
this.storageValue.nextElementSibling.textContent = 'GB';
|
||||
}
|
||||
|
||||
this.priceValue.textContent = pricing.price;
|
||||
this.planDescription.textContent = pricing.plan;
|
||||
}
|
||||
}
|
||||
|
||||
class Application {
|
||||
constructor() {
|
||||
this.pricingCalculator = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
this.pricingCalculator = new PricingCalculator();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const app = new Application();
|
||||
export default app;
|
||||
@ -34,8 +34,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
<div class="quota-actions">
|
||||
<a href="/users/${user.email}/details" class="btn-outline">View Details</a>
|
||||
<a href="/users/${user.email}/edit" class="btn-outline">Edit Quota</a>
|
||||
<button class="btn-outline delete-user-btn" data-email="${user.email}">Delete User</button>
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">Delete Team</button>` : ''}
|
||||
<button class="btn-outline delete-user-btn" data-email="${user.email}">🗑️</button>
|
||||
${user.parent_email === null ? `<button class="btn-outline delete-team-btn" data-parent-email="${user.email}">🗑️</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
userQuotaList.appendChild(quotaItem);
|
||||
|
||||
@ -115,62 +115,65 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
async function shareFile(paths, names) {
|
||||
const modal = document.getElementById('share-modal');
|
||||
const linkContainer = document.getElementById('share-link-container');
|
||||
const loading = document.getElementById('share-loading');
|
||||
const shareLinkInput = document.getElementById('share-link-input');
|
||||
const shareFileName = document.getElementById('share-file-name');
|
||||
const shareLinksList = document.getElementById('share-links-list');
|
||||
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');
|
||||
|
||||
shareLinkInput.value = '';
|
||||
if (shareLinksList) shareLinksList.innerHTML = '';
|
||||
linkContainer.style.display = 'none';
|
||||
loading.style.display = 'block';
|
||||
quickShareResult.style.display = 'none';
|
||||
quickShareLinkInput.value = '';
|
||||
modal.classList.add('show');
|
||||
|
||||
const currentPath = paths[0];
|
||||
const currentName = names[0];
|
||||
|
||||
if (paths.length === 1) {
|
||||
shareFileName.textContent = `Sharing: ${names[0]}`;
|
||||
shareFileName.textContent = `Sharing: ${currentName}`;
|
||||
} else {
|
||||
shareFileName.textContent = `Sharing ${paths.length} items`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/files/share_multiple`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ paths: paths })
|
||||
});
|
||||
const data = await response.json();
|
||||
generateQuickShareBtn.onclick = async function() {
|
||||
generateQuickShareBtn.disabled = true;
|
||||
generateQuickShareBtn.textContent = 'Generating...';
|
||||
|
||||
if (data.share_links && data.share_links.length > 0) {
|
||||
if (data.share_links.length === 1) {
|
||||
shareLinkInput.value = data.share_links[0];
|
||||
linkContainer.style.display = 'block';
|
||||
try {
|
||||
const response = await fetch('/api/sharing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
item_path: currentPath,
|
||||
permission: 'view',
|
||||
scope: 'public',
|
||||
expiration_days: 7
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
quickShareLinkInput.value = data.share_url;
|
||||
quickShareResult.style.display = 'block';
|
||||
generateQuickShareBtn.textContent = 'Generate Another Link';
|
||||
} else {
|
||||
// Display multiple links
|
||||
if (!shareLinksList) {
|
||||
// Create the list if it doesn't exist
|
||||
const ul = document.createElement('ul');
|
||||
ul.id = 'share-links-list';
|
||||
linkContainer.appendChild(ul);
|
||||
shareLinksList = ul;
|
||||
}
|
||||
data.share_links.forEach(item => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<strong>${item.name}:</strong> <input type="text" value="${item.link}" readonly class="form-input share-link-item-input"> <button class="btn-primary copy-share-link-item-btn" data-link="${item.link}">Copy</button>`;
|
||||
shareLinksList.appendChild(li);
|
||||
});
|
||||
linkContainer.style.display = 'block';
|
||||
alert('Error generating share link: ' + data.error);
|
||||
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
|
||||
}
|
||||
loading.style.display = 'none';
|
||||
} else {
|
||||
loading.textContent = 'Error generating share link(s)';
|
||||
} catch (error) {
|
||||
console.error('Error generating share link:', error);
|
||||
alert('Error generating share link');
|
||||
generateQuickShareBtn.textContent = 'Generate Quick Share Link';
|
||||
} finally {
|
||||
generateQuickShareBtn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing files:', error);
|
||||
loading.textContent = 'Error generating share link(s)';
|
||||
}
|
||||
};
|
||||
|
||||
advancedShareBtn.onclick = function() {
|
||||
window.location.href = `/sharing/create?item_path=${encodeURIComponent(currentPath)}`;
|
||||
};
|
||||
}
|
||||
|
||||
function copyShareLink() {
|
||||
@ -295,6 +298,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
copyShareLinkBtn.addEventListener('click', copyShareLink);
|
||||
}
|
||||
|
||||
const copyQuickShareBtn = document.getElementById('copy-quick-share-btn');
|
||||
if (copyQuickShareBtn) {
|
||||
copyQuickShareBtn.addEventListener('click', function() {
|
||||
const input = document.getElementById('quick-share-link-input');
|
||||
input.select();
|
||||
navigator.clipboard.writeText(input.value).then(() => {
|
||||
alert('Share link copied to clipboard');
|
||||
}).catch(err => {
|
||||
document.execCommand('copy');
|
||||
alert('Share link copied to clipboard');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('select-all')?.addEventListener('change', function(e) {
|
||||
const checkboxes = document.querySelectorAll('.file-checkbox');
|
||||
checkboxes.forEach(cb => cb.checked = e.target.checked);
|
||||
|
||||
@ -5,15 +5,12 @@
|
||||
<span class="brand-text">Retoor's</span>
|
||||
</a>
|
||||
<ul class="nav-menu">
|
||||
<li><a href="/solutions">Solutions</a></li>
|
||||
<li><a href="/pricing">Pricing</a></li>
|
||||
<li><a href="/security">Security</a></li>
|
||||
{% if request['user'] %}
|
||||
{% if request.get('user') %}
|
||||
<li><a href="/files">My Files</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="nav-actions">
|
||||
{% if request['user'] %}
|
||||
{% if request.get('user') %}
|
||||
<a href="/files" class="nav-link">Dashboard</a>
|
||||
<a href="/logout" class="btn-outline">Logout</a>
|
||||
{% else %}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
<div class="sidebar-menu">
|
||||
<ul>
|
||||
<li><a href="/files" {% if active_page == 'files' %}class="active"{% endif %}><img src="/static/images/icon-families.svg" alt="My Files Icon" class="icon"> My Files</a></li>
|
||||
<li><a href="/sharing/manage" {% if active_page == 'my_shares' %}class="active"{% endif %}><img src="/static/images/icon-professionals.svg" alt="My Shares Icon" class="icon"> My Shares</a></li>
|
||||
<li><a href="/shared" {% if active_page == 'shared' %}class="active"{% endif %}><img src="/static/images/icon-professionals.svg" alt="Shared Icon" class="icon"> Shared with me</a></li>
|
||||
<li><a href="/recent" {% if active_page == 'recent' %}class="active"{% endif %}><img src="/static/images/icon-students.svg" alt="Recent Icon" class="icon"> Recent</a></li>
|
||||
<li><a href="/favorites" {% if active_page == 'favorites' %}class="active"{% endif %}><img src="/static/images/icon-families.svg" alt="Favorites Icon" class="icon"> Favorites</a></li>
|
||||
|
||||
246
retoors/templates/pages/create_share.html
Normal file
246
retoors/templates/pages/create_share.html
Normal file
@ -0,0 +1,246 @@
|
||||
{% extends "layouts/dashboard.html" %}
|
||||
|
||||
{% block title %}Create Share - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block dashboard_head %}
|
||||
<link rel="stylesheet" href="/static/css/components/form.css">
|
||||
<style>
|
||||
.share-form-container {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.form-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.form-section h3 {
|
||||
margin-bottom: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.form-group input, .form-group select, .form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.checkbox-group input {
|
||||
width: auto;
|
||||
}
|
||||
.recipient-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.recipient-item {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-add-recipient {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.share-url-display {
|
||||
display: none;
|
||||
margin-top: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f0f7ff;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.share-url-display input {
|
||||
font-family: monospace;
|
||||
background: white;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}Create Share{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="share-form-container">
|
||||
<form id="create-share-form">
|
||||
<div class="form-section">
|
||||
<h3>Basic Information</h3>
|
||||
<div class="form-group">
|
||||
<label for="item_path">Item to Share</label>
|
||||
<input type="text" id="item_path" name="item_path" value="{{ item_path }}" required readonly>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="permission">Permission Level</label>
|
||||
<select id="permission" name="permission">
|
||||
<option value="view">View Only - Can view and download</option>
|
||||
<option value="edit">Edit - Can modify, add, and delete</option>
|
||||
<option value="comment">Comment - Can provide feedback only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="scope">Sharing Scope</label>
|
||||
<select id="scope" name="scope">
|
||||
<option value="public">Public - Anyone with the link</option>
|
||||
<option value="private">Private - Specific recipients only</option>
|
||||
<option value="account_based">Account Based - Requires user account</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section" id="recipients-section" style="display: none;">
|
||||
<h3>Recipients</h3>
|
||||
<div class="form-group">
|
||||
<label>Recipient Emails</label>
|
||||
<div class="recipient-list" id="recipient-list">
|
||||
<div class="recipient-item">
|
||||
<input type="email" class="recipient-email" placeholder="email@example.com">
|
||||
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn-outline btn-add-recipient" id="add-recipient-btn">Add Recipient</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-section">
|
||||
<h3>Security Options</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Password Protection</label>
|
||||
<input type="password" id="password" name="password" placeholder="Optional password">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="expiration_days">Expiration</label>
|
||||
<select id="expiration_days" name="expiration_days">
|
||||
<option value="">Never expires</option>
|
||||
<option value="1">1 day</option>
|
||||
<option value="7" selected>7 days</option>
|
||||
<option value="30">30 days</option>
|
||||
<option value="90">90 days</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<input type="checkbox" id="disable_download" name="disable_download">
|
||||
<label for="disable_download">Disable downloads (view only)</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary">Create Share Link</button>
|
||||
<button type="button" class="btn-outline" onclick="history.back()">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="share-url-display" id="share-url-display">
|
||||
<h3>Share Link Created</h3>
|
||||
<div class="form-group">
|
||||
<label>Share URL</label>
|
||||
<input type="text" id="share-url" readonly>
|
||||
<button type="button" class="btn-primary" id="copy-url-btn">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module">
|
||||
const form = document.getElementById('create-share-form');
|
||||
const scopeSelect = document.getElementById('scope');
|
||||
const recipientsSection = document.getElementById('recipients-section');
|
||||
const addRecipientBtn = document.getElementById('add-recipient-btn');
|
||||
const recipientList = document.getElementById('recipient-list');
|
||||
const shareUrlDisplay = document.getElementById('share-url-display');
|
||||
const shareUrlInput = document.getElementById('share-url');
|
||||
const copyUrlBtn = document.getElementById('copy-url-btn');
|
||||
|
||||
scopeSelect.addEventListener('change', () => {
|
||||
if (scopeSelect.value === 'private' || scopeSelect.value === 'account_based') {
|
||||
recipientsSection.style.display = 'block';
|
||||
} else {
|
||||
recipientsSection.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
addRecipientBtn.addEventListener('click', () => {
|
||||
const recipientItem = document.createElement('div');
|
||||
recipientItem.className = 'recipient-item';
|
||||
recipientItem.innerHTML = `
|
||||
<input type="email" class="recipient-email" placeholder="email@example.com">
|
||||
<button type="button" class="btn-small btn-remove-recipient">Remove</button>
|
||||
`;
|
||||
recipientList.appendChild(recipientItem);
|
||||
});
|
||||
|
||||
recipientList.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('btn-remove-recipient')) {
|
||||
e.target.parentElement.remove();
|
||||
}
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
item_path: document.getElementById('item_path').value,
|
||||
permission: document.getElementById('permission').value,
|
||||
scope: document.getElementById('scope').value,
|
||||
password: document.getElementById('password').value || null,
|
||||
expiration_days: parseInt(document.getElementById('expiration_days').value) || null,
|
||||
disable_download: document.getElementById('disable_download').checked,
|
||||
recipient_emails: []
|
||||
};
|
||||
|
||||
if (formData.scope === 'private' || formData.scope === 'account_based') {
|
||||
const emailInputs = document.querySelectorAll('.recipient-email');
|
||||
formData.recipient_emails = Array.from(emailInputs)
|
||||
.map(input => input.value.trim())
|
||||
.filter(email => email);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sharing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
shareUrlInput.value = result.share_url;
|
||||
shareUrlDisplay.style.display = 'block';
|
||||
form.style.display = 'none';
|
||||
} else {
|
||||
alert('Error: ' + result.error);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error creating share: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
copyUrlBtn.addEventListener('click', () => {
|
||||
shareUrlInput.select();
|
||||
document.execCommand('copy');
|
||||
copyUrlBtn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
copyUrlBtn.textContent = 'Copy Link';
|
||||
}, 2000);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@ -57,7 +57,7 @@
|
||||
<button class="btn-outline" onclick="alert('Download feature coming soon!')">Download</button>
|
||||
<button class="btn-outline" onclick="alert('Upload feature coming soon!')">Upload</button>
|
||||
<button class="btn-outline" onclick="alert('Share feature coming soon!')">Share</button>
|
||||
<button class="btn-outline" onclick="alert('Delete feature coming soon!')">Delete</button>
|
||||
<button class="btn-outline" onclick="alert('Delete feature coming soon!')">🗑️</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -71,7 +71,7 @@
|
||||
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
|
||||
{% endif %}
|
||||
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
|
||||
<button class="btn-small btn-danger remove-favorite-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Remove</button>
|
||||
<button class="btn-small btn-danger remove-favorite-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -111,7 +111,7 @@
|
||||
<p id="remove-favorite-message"></p>
|
||||
<form id="remove-favorite-form" method="post">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-danger">Remove</button>
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('remove-favorite-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -13,7 +13,7 @@
|
||||
<button class="btn-outline" id="upload-btn">Upload</button>
|
||||
<button class="btn-outline" id="download-selected-btn" disabled>⬇️</button>
|
||||
<button class="btn-outline" id="share-selected-btn" disabled>đź”—</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
@ -79,7 +79,7 @@
|
||||
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
|
||||
{% endif %}
|
||||
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -131,20 +131,46 @@
|
||||
<div id="share-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal('share-modal')">×</span>
|
||||
<h3>Share File</h3>
|
||||
<h3>Share Item</h3>
|
||||
<p id="share-file-name"></p>
|
||||
<div id="share-link-container" style="display: none;">
|
||||
<input type="text" id="share-link-input" readonly class="form-input">
|
||||
<button class="btn-primary" id="copy-share-link-btn">Copy Link</button>
|
||||
<div id="share-links-list" class="share-links-list"></div>
|
||||
|
||||
<div class="share-options">
|
||||
<h4>Quick Share (Public Link)</h4>
|
||||
<p style="color: #666; font-size: 0.9em; margin-bottom: 1rem;">Generate a simple public link that anyone can access</p>
|
||||
<div id="quick-share-container">
|
||||
<button class="btn-primary" id="generate-quick-share-btn">Generate Quick Share Link</button>
|
||||
<div id="quick-share-result" style="display: none; margin-top: 1rem;">
|
||||
<input type="text" id="quick-share-link-input" readonly class="form-input">
|
||||
<button class="btn-outline" id="copy-quick-share-btn">Copy Link</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 2rem 0;">
|
||||
|
||||
<h4>Advanced Sharing</h4>
|
||||
<p style="color: #666; font-size: 0.9em; margin-bottom: 1rem;">Configure permissions, passwords, expiration, and more</p>
|
||||
<button class="btn-primary" id="advanced-share-btn">Create Advanced Share</button>
|
||||
</div>
|
||||
<div id="share-loading">Generating share link...</div>
|
||||
<div class="modal-actions">
|
||||
|
||||
<div class="modal-actions" style="margin-top: 2rem;">
|
||||
<button type="button" class="btn-outline" onclick="closeModal('share-modal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.share-options {
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.share-options h4 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
#quick-share-result input {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="delete-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeModal('delete-modal')">×</span>
|
||||
@ -152,7 +178,7 @@
|
||||
<p id="delete-message"></p>
|
||||
<form id="delete-form" method="post">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Secure Cloud Storage for Everyone - Retoor's Cloud Solutions{% endblock %}
|
||||
{% block title %}Secure Cloud Storage - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/css/components/index.css">
|
||||
@ -10,113 +10,44 @@
|
||||
<main>
|
||||
<section class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1>Your files, safe and accessible everywhere</h1>
|
||||
<p class="hero-subtitle">Store, sync, and share your files with enterprise-grade security. Access from any device, anytime, anywhere.</p>
|
||||
<div class="hero-ctas">
|
||||
<a href="/register" class="btn-primary hero-btn">Get Started Free</a>
|
||||
<a href="/pricing" class="btn-outline hero-btn">View Pricing</a>
|
||||
</div>
|
||||
<p class="hero-subtext">No credit card required • 10 GB free storage</p>
|
||||
<h1>Secure Cloud Storage</h1>
|
||||
<p class="hero-subtitle">Store, sync, and share your files with enterprise-grade security.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="features-section">
|
||||
<div class="features-header">
|
||||
<h2>Everything you need to work smarter</h2>
|
||||
<p>Powerful features designed to keep your data secure and accessible</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Bank-level Security</h3>
|
||||
<p>256-bit AES encryption and TLS 1.3 protocol ensure your files stay private and protected</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2"/>
|
||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Access Anywhere</h3>
|
||||
<p>Seamlessly sync across all your devices. Desktop, mobile, or web - your files are always there</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="9" cy="7" r="4"/>
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Easy Sharing</h3>
|
||||
<p>Share files and folders with anyone using secure links. Control access with permissions</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>High Performance</h3>
|
||||
<p>Lightning-fast upload and download speeds powered by enterprise infrastructure</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||
<polyline points="3.27 6.96 12 12.01 20.73 6.96"/>
|
||||
<line x1="12" y1="22.08" x2="12" y2="12"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Auto Backup</h3>
|
||||
<p>Never lose important files. Automatic backups keep multiple versions of your documents</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="10"/>
|
||||
<polyline points="12 6 12 12 16 14"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>24/7 Support</h3>
|
||||
<p>Expert support team ready to help whenever you need. Email, chat, and phone support available</p>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<section class="use-cases-section">
|
||||
<h2>Perfect for every need</h2>
|
||||
<div class="use-cases-grid">
|
||||
<div class="use-case-card">
|
||||
<img src="/static/images/icon-families.svg" alt="Families Icon" class="use-case-icon">
|
||||
<h3>For Families</h3>
|
||||
<p>Keep precious memories safe. Share photos and videos with family members securely.</p>
|
||||
<div class="storage-display">
|
||||
<span class="storage-value" id="storageValue">100</span>
|
||||
<span class="storage-unit">GB</span>
|
||||
</div>
|
||||
<div class="use-case-card">
|
||||
<img src="/static/images/icon-professionals.svg" alt="Professionals Icon" class="use-case-icon">
|
||||
<h3>For Professionals</h3>
|
||||
<p>Collaborate on projects, share documents, and work from anywhere with confidence.</p>
|
||||
</div>
|
||||
<div class="use-case-card">
|
||||
<img src="/static/images/icon-students.svg" alt="Students Icon" class="use-case-icon">
|
||||
<h3>For Students</h3>
|
||||
<p>Store all your coursework, projects, and study materials in one secure place.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cta-section">
|
||||
<div class="cta-content">
|
||||
<h2>Start storing your files securely today</h2>
|
||||
<p>Join thousands of users who trust Retoor's Cloud Solutions</p>
|
||||
<a href="/register" class="btn-primary cta-btn">Create Free Account</a>
|
||||
<div class="slider-container">
|
||||
<input type="range" id="storageSlider" min="1" max="2000" value="100" step="1">
|
||||
<div class="slider-labels">
|
||||
<span>1 GB</span>
|
||||
<span>2 TB</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="price-display">
|
||||
<div class="price-amount">
|
||||
<span class="currency">$</span>
|
||||
<span class="price-value" id="priceValue">9</span>
|
||||
<span class="price-period">/month</span>
|
||||
</div>
|
||||
<p class="price-description" id="planDescription">Personal Plan</p>
|
||||
</div>
|
||||
|
||||
<a href="/register" class="btn-primary cta-btn">Get Started</a>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="module" src="/static/js/components/pricing.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
381
retoors/templates/pages/manage_shares.html
Normal file
381
retoors/templates/pages/manage_shares.html
Normal file
@ -0,0 +1,381 @@
|
||||
{% extends "layouts/dashboard.html" %}
|
||||
|
||||
{% block title %}Manage Shares - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block dashboard_head %}
|
||||
<style>
|
||||
.shares-container {
|
||||
padding: 1.5rem;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.shares-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
.share-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
border-left: 4px solid #0066cc;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
.share-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
.share-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
.share-item-path {
|
||||
font-weight: 600;
|
||||
font-size: 1.1rem;
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
flex: 1;
|
||||
margin-right: 1rem;
|
||||
}
|
||||
.share-status {
|
||||
padding: 0.4rem 0.8rem;
|
||||
border-radius: 16px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-status.active {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
.share-status.inactive {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
.share-status.expired {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
.share-card-body {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.share-info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.share-info-label {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
.share-info-value {
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
.share-link {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #0066cc;
|
||||
background: #f0f7ff;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin: 0.75rem 0;
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
.share-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.share-actions button {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 0.6rem 0.8rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 4rem 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.empty-state-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
.empty-state h3 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
.empty-state p {
|
||||
color: #666;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.recipient-list {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.recipient-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: #f5f5f5;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.shares-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_title %}Manage Shares{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
<div class="shares-container">
|
||||
{% if shares %}
|
||||
<div class="shares-grid" id="shares-list">
|
||||
{% for share in shares %}
|
||||
<div class="share-card" data-share-id="{{ share.share_id }}">
|
||||
<div class="share-card-header">
|
||||
<div class="share-item-path">{{ share.item_path }}</div>
|
||||
{% if not share.active %}
|
||||
<span class="share-status inactive">Inactive</span>
|
||||
{% elif share.expires_at and share.expires_at < now %}
|
||||
<span class="share-status expired">Expired</span>
|
||||
{% else %}
|
||||
<span class="share-status active">Active</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="share-card-body">
|
||||
<div class="share-link">/share/{{ share.share_id }}</div>
|
||||
|
||||
<div class="share-info-row">
|
||||
<span class="share-info-label">Permission</span>
|
||||
<span class="share-info-value">{{ share.permission }}</span>
|
||||
</div>
|
||||
|
||||
<div class="share-info-row">
|
||||
<span class="share-info-label">Scope</span>
|
||||
<span class="share-info-value">{{ share.scope }}</span>
|
||||
</div>
|
||||
|
||||
<div class="share-info-row">
|
||||
<span class="share-info-label">Access Count</span>
|
||||
<span class="share-info-value">{{ share.access_count or 0 }} views</span>
|
||||
</div>
|
||||
|
||||
<div class="share-info-row">
|
||||
<span class="share-info-label">Created</span>
|
||||
<span class="share-info-value">{{ share.created_at[:10] }}</span>
|
||||
</div>
|
||||
|
||||
<div class="share-info-row">
|
||||
<span class="share-info-label">Expires</span>
|
||||
<span class="share-info-value">{{ share.expires_at[:10] if share.expires_at else 'Never' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="share-actions">
|
||||
<button class="btn-small copy-link-btn" data-share-id="{{ share.share_id }}">Copy Link</button>
|
||||
<button class="btn-small view-details-btn" data-share-id="{{ share.share_id }}">Details</button>
|
||||
{% if share.active %}
|
||||
<button class="btn-small deactivate-btn" data-share-id="{{ share.share_id }}">Deactivate</button>
|
||||
{% else %}
|
||||
<button class="btn-small activate-btn" data-share-id="{{ share.share_id }}">Activate</button>
|
||||
{% endif %}
|
||||
<button class="btn-small delete-btn btn-danger" data-share-id="{{ share.share_id }}">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-state-icon">đź”—</div>
|
||||
<h3>No Shares Yet</h3>
|
||||
<p>You have not created any shares. Start sharing your files and folders with others.</p>
|
||||
<a href="/files" class="btn-primary">Go to My Files</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="modal" id="share-details-modal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h2>Share Details</h2>
|
||||
<button class="modal-close">×</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,81 +0,0 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Pricing - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/css/components/pricing.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="pricing-hero">
|
||||
<h1>Simple, Transparent Pricing</h1>
|
||||
<p>Find the perfect plan for your needs.</p>
|
||||
<div class="pricing-toggle">
|
||||
<button class="btn-toggle active" data-period="monthly">Monthly</button>
|
||||
<button class="btn-toggle" data-period="annually">Annually</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pricing-tiers">
|
||||
<div class="pricing-card">
|
||||
<h3>Free</h3>
|
||||
<p class="price">$0<span>/month</span></p>
|
||||
<ul>
|
||||
<li>1 GB Storage</li>
|
||||
<li>Basic Sync & Share</li>
|
||||
<li>Standard Support</li>
|
||||
</ul>
|
||||
<a href="/register" class="btn-primary">Get Started</a>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card featured">
|
||||
<h3>Personal</h3>
|
||||
<p class="price">$9<span>/month</span></p>
|
||||
<ul>
|
||||
<li>100 GB Storage</li>
|
||||
<li>Advanced Sync & Share</li>
|
||||
<li>Priority Support</li>
|
||||
<li>Version History</li>
|
||||
</ul>
|
||||
<a href="/register" class="btn-primary">Sign Up Now</a>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card">
|
||||
<h3>Professional</h3>
|
||||
<p class="price">$29<span>/month</span></p>
|
||||
<ul>
|
||||
<li>1 TB Storage</li>
|
||||
<li>Team Collaboration</li>
|
||||
<li>24/7 Premium Support</li>
|
||||
<li>Advanced Security</li>
|
||||
</ul>
|
||||
<a href="/register" class="btn-primary">Sign Up Now</a>
|
||||
</div>
|
||||
|
||||
<div class="pricing-card">
|
||||
<h3>Business</h3>
|
||||
<p class="price">$99<span>/month</span></p>
|
||||
<ul>
|
||||
<li>Unlimited Storage</li>
|
||||
<li>Custom Solutions</li>
|
||||
<li>Dedicated Account Manager</li>
|
||||
<li>Advanced Analytics</li>
|
||||
</ul>
|
||||
<a href="/support" class="btn-primary">Contact Sales</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="pricing-faq">
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
<div class="faq-item">
|
||||
<h3>Can I change my plan later?</h3>
|
||||
<p>Yes, you can upgrade or downgrade your plan at any time from your dashboard.</p>
|
||||
</div>
|
||||
<div class="faq-item">
|
||||
<h3>What payment methods do you accept?</h3>
|
||||
<p>We accept all major credit cards, PayPal, and bank transfers for annual plans.</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -52,8 +52,14 @@
|
||||
<img src="/static/images/icon-families.svg" alt="Folder Icon" class="file-icon">
|
||||
<a href="/files?path={{ item.path }}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
|
||||
{{ item.name }}
|
||||
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
|
||||
{% if item.is_editable %}
|
||||
<a href="/editor?path={{ item.path }}">{{ item.name }}</a>
|
||||
{% elif item.is_viewable %}
|
||||
<a href="/viewer?path={{item.path}}">{{ item.name }}</a>
|
||||
{% else %}
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.email }}</td>
|
||||
@ -119,4 +125,4 @@
|
||||
</div>
|
||||
|
||||
<script type="module" src="/static/js/main.js"></script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@ -1,62 +0,0 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Security - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/css/components/security.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="security-hero">
|
||||
<h1>Your Data, Our Priority. Uncompromising Security.</h1>
|
||||
<p>At Retoor's Cloud Solutions, we understand the critical importance of data security and privacy. We employ industry-leading measures to ensure your information is always protected.</p>
|
||||
</section>
|
||||
|
||||
<section class="security-pillars">
|
||||
<div class="pillar-card">
|
||||
<img src="/static/images/icon-professionals.svg" alt="Encryption Icon" class="icon">
|
||||
<h3>Robust Encryption</h3>
|
||||
<p>All your data is encrypted both in transit (TLS 1.2+) and at rest (AES-256), ensuring maximum confidentiality and integrity.</p>
|
||||
</div>
|
||||
<div class="pillar-card">
|
||||
<img src="/static/images/icon-professionals.svg" alt="Access Control Icon" class="icon">
|
||||
<h3>Advanced Access Control</h3>
|
||||
<p>Implement granular permissions, multi-factor authentication (MFA), and strict access policies to keep your data safe from unauthorized access.</p>
|
||||
</div>
|
||||
<div class="pillar-card">
|
||||
<img src="/static/images/icon-families.svg" alt="Privacy Icon" class="icon">
|
||||
<h3>Unwavering Privacy</h3>
|
||||
<p>We are committed to your privacy. Our policies are transparent, and we comply with global data protection regulations like GDPR and CCPA.</p>
|
||||
</div>
|
||||
<div class="pillar-card">
|
||||
<img src="/static/images/icon-professionals.svg" alt="Infrastructure Icon" class="icon">
|
||||
<h3>Secure Infrastructure</h3>
|
||||
<p>Our data centers are physically secured, and our network is protected by advanced firewalls and intrusion detection systems, regularly audited for vulnerabilities.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="security-certifications">
|
||||
<h2>Trust & Compliance</h2>
|
||||
<div class="cert-grid">
|
||||
<div class="cert-item">
|
||||
<img src="/static/images/icon-professionals.svg" alt="ISO 27001 Certified" class="cert-logo">
|
||||
<p>ISO 27001 Certified</p>
|
||||
</div>
|
||||
<div class="cert-item">
|
||||
<img src="/static/images/icon-professionals.svg" alt="SOC 2 Compliant" class="cert-logo">
|
||||
<p>SOC 2 Compliant</p>
|
||||
</div>
|
||||
<div class="cert-item">
|
||||
<img src="/static/images/icon-families.svg" alt="GDPR Compliant" class="cert-logo">
|
||||
<p>GDPR Compliant</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="security-cta">
|
||||
<h2>Have More Questions About Security?</h2>
|
||||
<a href="/support" class="btn-primary">Contact Support</a>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
34
retoors/templates/pages/share_error.html
Normal file
34
retoors/templates/pages/share_error.html
Normal file
@ -0,0 +1,34 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Share Error{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.error-container {
|
||||
max-width: 600px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
.error-icon {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.error-message {
|
||||
color: #666;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-container">
|
||||
<div class="error-icon">đź”’</div>
|
||||
<h1>Access Denied</h1>
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<a href="/" class="btn-primary">Go to Homepage</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
81
retoors/templates/pages/share_file.html
Normal file
81
retoors/templates/pages/share_file.html
Normal file
@ -0,0 +1,81 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Shared File - {{ file_name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.share-container {
|
||||
max-width: 800px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.share-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.file-icon-large {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin: 0 auto 1rem;
|
||||
display: block;
|
||||
}
|
||||
.file-info {
|
||||
display: grid;
|
||||
grid-template-columns: 150px 1fr;
|
||||
gap: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
.file-info dt {
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
.permission-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="share-container">
|
||||
<div class="share-header">
|
||||
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon-large">
|
||||
<h1>{{ file_name }}</h1>
|
||||
<span class="permission-badge">{{ permission }}</span>
|
||||
</div>
|
||||
|
||||
<dl class="file-info">
|
||||
<dt>File Name</dt>
|
||||
<dd>{{ file_name }}</dd>
|
||||
|
||||
<dt>Size</dt>
|
||||
<dd>{{ (file_size / 1024 / 1024)|round(2) }} MB</dd>
|
||||
|
||||
<dt>Path</dt>
|
||||
<dd>{{ item_path }}</dd>
|
||||
|
||||
<dt>Permission</dt>
|
||||
<dd>{{ permission }}</dd>
|
||||
</dl>
|
||||
|
||||
<div class="file-actions">
|
||||
{% if not disable_download %}
|
||||
<a href="/share/{{ share_id }}/download" class="btn-primary" download>Download File</a>
|
||||
{% else %}
|
||||
<button class="btn-outline" disabled>Download Disabled</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
114
retoors/templates/pages/share_folder.html
Normal file
114
retoors/templates/pages/share_folder.html
Normal file
@ -0,0 +1,114 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Shared Folder - {{ item_path }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.share-container {
|
||||
max-width: 1200px;
|
||||
margin: 4rem auto;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.share-header {
|
||||
border-bottom: 1px solid #eee;
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.permission-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
border-radius: 12px;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
.file-list-table {
|
||||
width: 100%;
|
||||
}
|
||||
.file-list-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.file-list-table th {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
}
|
||||
.file-list-table td {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
.file-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-right: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="share-container">
|
||||
<div class="share-header">
|
||||
<h1>
|
||||
{{ item_path.split('/')[-1] if item_path else 'Shared Folder' }}
|
||||
<span class="permission-badge">{{ permission }}</span>
|
||||
</h1>
|
||||
<p>{{ item_path }}</p>
|
||||
</div>
|
||||
|
||||
<div class="file-list-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Last Modified</th>
|
||||
<th>Size</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if files %}
|
||||
{% for item in files %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if item.is_dir %}
|
||||
<img src="/static/images/icon-families.svg" alt="Folder Icon" class="file-icon">
|
||||
{{ item.name }}
|
||||
{% else %}
|
||||
<img src="/static/images/icon-professionals.svg" alt="File Icon" class="file-icon">
|
||||
{{ item.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.last_modified[:10] if item.last_modified else '' }}</td>
|
||||
<td>
|
||||
{% if item.is_dir %}
|
||||
--
|
||||
{% else %}
|
||||
{{ (item.size / 1024 / 1024)|round(2) }} MB
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if not item.is_dir and not disable_download %}
|
||||
<a href="/share/{{ share_id }}/download?file_path={{ item.path }}" class="btn-small">Download</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; padding: 2rem; color: #999;">
|
||||
This folder is empty
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@ -13,7 +13,7 @@
|
||||
<button class="btn-outline" id="upload-btn">Upload</button>
|
||||
<button class="btn-outline" id="download-selected-btn" disabled>⬇️</button>
|
||||
<button class="btn-outline" id="share-selected-btn" disabled>đź”—</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
@ -73,7 +73,7 @@
|
||||
<button class="btn-small download-file-btn" data-path="{{ item.path }}">⬇️</button>
|
||||
{% endif %}
|
||||
<button class="btn-small share-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">đź”—</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -144,7 +144,7 @@
|
||||
<p id="delete-message"></p>
|
||||
<form id="delete-form" method="post">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-danger">Delete</button>
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -1,75 +0,0 @@
|
||||
{% extends "layouts/base.html" %}
|
||||
|
||||
{% block title %}Solutions - Retoor's Cloud Solutions{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="/static/css/components/solutions.css">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main>
|
||||
<section class="solutions-hero">
|
||||
<h1>Powerful Cloud Solutions for Modern Needs</h1>
|
||||
<p>Discover how Retoor's Cloud Solutions can empower your family, business, or academic pursuits with secure, accessible, and collaborative tools.</p>
|
||||
</section>
|
||||
|
||||
<section class="solution-sections">
|
||||
<div class="solution-card">
|
||||
<img src="/static/images/icon-families.svg" alt="Family Storage Icon" class="icon">
|
||||
<h2>Secure Family Storage</h2>
|
||||
<p>Keep your family's precious memories safe and accessible. Securely store photos, videos, and important documents, and easily share them with loved ones.</p>
|
||||
<ul>
|
||||
<li>Private photo and video galleries</li>
|
||||
<li>Shared family albums</li>
|
||||
<li>Secure document vault for wills, deeds, etc.</li>
|
||||
<li>Easy sharing with granular permissions</li>
|
||||
</ul>
|
||||
<a href="/pricing" class="btn-primary">See Family Plans</a>
|
||||
</div>
|
||||
|
||||
<div class="solution-card">
|
||||
<img src="/static/images/icon-professionals.svg" alt="Business Collaboration Icon" class="icon">
|
||||
<h2>Business Collaboration & Productivity</h2>
|
||||
<p>Boost your team's efficiency with seamless collaboration tools. Share files, co-edit documents, and manage projects from anywhere, securely.</p>
|
||||
<ul>
|
||||
<li>Real-time document co-editing</li>
|
||||
<li>Secure file sharing with external partners</li>
|
||||
<li>Version control and recovery</li>
|
||||
<li>Team workspaces and project folders</li>
|
||||
</ul>
|
||||
<a href="/pricing" class="btn-primary">Explore Business Solutions</a>
|
||||
</div>
|
||||
|
||||
<div class="solution-card">
|
||||
<img src="/static/images/icon-students.svg" alt="Academic Storage Icon" class="icon">
|
||||
<h2>Academic & Student Resources</h2>
|
||||
<p>Organize your academic life with dedicated storage for projects, research, and notes. Access your study materials across all your devices.</p>
|
||||
<ul>
|
||||
<li>Centralized storage for assignments and research</li>
|
||||
<li>Easy access from campus or home</li>
|
||||
<li>Secure sharing for group projects</li>
|
||||
<li>Integration with academic tools (coming soon)</li>
|
||||
</ul>
|
||||
<a href="/pricing" class="btn-primary">View Student Plans</a>
|
||||
</div>
|
||||
|
||||
<div class="solution-card">
|
||||
<img src="/static/images/icon-families.svg" alt="Automated Backup & Recovery Icon" class="icon">
|
||||
<h2>Automated Backup & Recovery</h2>
|
||||
<p>Never lose a file again. Our automated backup solutions ensure your data is always safe, with easy recovery options for any scenario.</p>
|
||||
<ul>
|
||||
<li>Scheduled automatic backups</li>
|
||||
<li>Point-in-time recovery</li>
|
||||
<li>Disaster recovery planning</li>
|
||||
<li>Secure, off-site data replication</li>
|
||||
</ul>
|
||||
<a href="/pricing" class="btn-primary">Learn About Backup</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="solutions-cta">
|
||||
<h2>Ready to Explore Our Plans?</h2>
|
||||
<a href="/pricing" class="btn-primary">View All Pricing</a>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@ -10,7 +10,7 @@
|
||||
|
||||
{% block dashboard_actions %}
|
||||
<button class="btn-outline" id="restore-selected-btn" disabled>Restore</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>Delete Permanently</button>
|
||||
<button class="btn-outline" id="delete-selected-btn" disabled>🗑️</button>
|
||||
{% endblock %}
|
||||
|
||||
{% block dashboard_content %}
|
||||
@ -67,7 +67,7 @@
|
||||
<td>
|
||||
<div class="action-buttons">
|
||||
<button class="btn-small restore-file-btn" data-path="{{ item.path }}">Restore</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">Delete Permanently</button>
|
||||
<button class="btn-small btn-danger delete-file-btn" data-path="{{ item.path }}" data-name="{{ item.name }}">🗑️</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@ -104,7 +104,7 @@
|
||||
<p id="delete-message"></p>
|
||||
<form id="delete-form" method="post">
|
||||
<div class="modal-actions">
|
||||
<button type="submit" class="btn-danger">Delete Permanently</button>
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
<button type="button" class="btn-outline" onclick="closeModal('delete-modal')">Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -44,7 +44,7 @@
|
||||
<div style="display: flex; gap: 10px; margin-top: 30px;">
|
||||
<a href="/users/{{ user_data.email }}/edit" class="btn-primary">Edit Quota</a>
|
||||
<form action="/users/{{ user_data.email }}/delete" method="post" style="display: inline;" onsubmit="return confirm('Are you sure you want to delete this user?');">
|
||||
<button type="submit" class="btn-danger">Delete User</button>
|
||||
<button type="submit" class="btn-danger">🗑️</button>
|
||||
</form>
|
||||
<a href="/users" class="btn-outline">Back to Users</a>
|
||||
</div>
|
||||
|
||||
381
retoors/views/sharing.py
Normal file
381
retoors/views/sharing.py
Normal file
@ -0,0 +1,381 @@
|
||||
import aiohttp_jinja2
|
||||
from aiohttp import web
|
||||
import json
|
||||
import logging
|
||||
from ..helpers.email_sender import send_email
|
||||
from ..helpers.auth import login_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def send_share_invitations(app, sender_email, recipient_emails, item_path, share_url, permission, password):
|
||||
|
||||
for recipient in recipient_emails:
|
||||
subject = f"{sender_email} shared '{item_path}' with you"
|
||||
|
||||
password_note = ""
|
||||
if password:
|
||||
password_note = f"<p><strong>This share is password protected.</strong> You will need to enter the password to access it.</p>"
|
||||
|
||||
body = f"""
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
|
||||
<h2>You have been invited to access a shared item</h2>
|
||||
|
||||
<p><strong>{sender_email}</strong> has shared <strong>{item_path}</strong> with you.</p>
|
||||
|
||||
<div style="background: #f5f5f5; padding: 15px; border-radius: 4px; margin: 20px 0;">
|
||||
<p><strong>Permission Level:</strong> {permission}</p>
|
||||
<p><strong>Item:</strong> {item_path}</p>
|
||||
</div>
|
||||
|
||||
{password_note}
|
||||
|
||||
<p>Click the button below to access the shared item:</p>
|
||||
|
||||
<a href="{share_url}" style="display: inline-block; background: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 4px; margin: 20px 0;">
|
||||
Access Shared Item
|
||||
</a>
|
||||
|
||||
<p>Or copy and paste this link into your browser:</p>
|
||||
<p style="background: #f5f5f5; padding: 10px; border-radius: 4px; word-break: break-all;">
|
||||
{share_url}
|
||||
</p>
|
||||
|
||||
<hr style="margin: 30px 0; border: none; border-top: 1px solid #ddd;">
|
||||
|
||||
<p style="color: #666; font-size: 12px;">
|
||||
This invitation was sent by Retoor's Cloud Solutions.<br>
|
||||
If you believe you received this email in error, please disregard it.
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
try:
|
||||
await send_email(app, recipient, subject, body)
|
||||
logger.info(f"Sent share invitation to {recipient} for {item_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send share invitation to {recipient}: {e}")
|
||||
|
||||
@login_required
|
||||
@aiohttp_jinja2.template('pages/create_share.html')
|
||||
async def create_share_page(request: web.Request):
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
item_path = request.query.get('item_path', '')
|
||||
|
||||
return {
|
||||
'request': request,
|
||||
'user_email': user_email,
|
||||
'item_path': item_path,
|
||||
'active_page': 'my_shares',
|
||||
'user': user
|
||||
}
|
||||
|
||||
@login_required
|
||||
async def create_share_handler(request: web.Request):
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
item_path = data.get('item_path')
|
||||
if not item_path:
|
||||
return web.json_response({'error': 'item_path is required'}, status=400)
|
||||
|
||||
permission = data.get('permission', 'view')
|
||||
scope = data.get('scope', 'public')
|
||||
password = data.get('password')
|
||||
expiration_days = data.get('expiration_days')
|
||||
disable_download = data.get('disable_download', False)
|
||||
recipient_emails = data.get('recipient_emails', [])
|
||||
|
||||
file_service = request.app['file_service']
|
||||
|
||||
try:
|
||||
share_id = await file_service.generate_share_link(
|
||||
user_email=user_email,
|
||||
item_path=item_path,
|
||||
permission=permission,
|
||||
scope=scope,
|
||||
password=password,
|
||||
expiration_days=expiration_days,
|
||||
disable_download=disable_download,
|
||||
recipient_emails=recipient_emails
|
||||
)
|
||||
|
||||
if share_id:
|
||||
share_url = str(request.url.origin()) + f'/share/{share_id}'
|
||||
|
||||
if recipient_emails and len(recipient_emails) > 0:
|
||||
logger.info(f"Sending email invitations to {len(recipient_emails)} recipients: {recipient_emails}")
|
||||
await send_share_invitations(
|
||||
request.app,
|
||||
user_email,
|
||||
recipient_emails,
|
||||
item_path,
|
||||
share_url,
|
||||
permission,
|
||||
password
|
||||
)
|
||||
else:
|
||||
logger.debug(f"No recipients specified for share {share_id}, skipping email invitations")
|
||||
|
||||
return web.json_response({
|
||||
'success': True,
|
||||
'share_id': share_id,
|
||||
'share_url': share_url
|
||||
})
|
||||
else:
|
||||
return web.json_response({'error': 'Failed to create share'}, status=400)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating share: {e}")
|
||||
return web.json_response({'error': str(e)}, status=500)
|
||||
|
||||
async def view_share(request: web.Request):
|
||||
share_id = request.match_info['share_id']
|
||||
file_service = request.app['file_service']
|
||||
|
||||
password = request.query.get('password')
|
||||
accessor_email = request.get('user', {}).get('email') if request.get('user') else None
|
||||
|
||||
shared_item = await file_service.get_shared_item(share_id, password, accessor_email)
|
||||
|
||||
share = await file_service.sharing_service.get_share(share_id)
|
||||
if not share:
|
||||
# Check for specific reasons why the share might not be found
|
||||
# (e.g., expired, deactivated, or truly not found)
|
||||
# This requires deeper inspection within sharing_service or replicating its logic,
|
||||
# for now, a generic "not found" is sufficient if get_share returns None.
|
||||
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
|
||||
'request': request,
|
||||
'error': 'Share link is invalid, expired, or deactivated.'
|
||||
})
|
||||
|
||||
# Now verify access with the retrieved share object
|
||||
if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email):
|
||||
# Determine more specific access denial reasons
|
||||
error_message = 'Access to this share is denied.'
|
||||
# Check for password requirement
|
||||
if share.get("password_hash") and not password:
|
||||
error_message = 'This share is password protected. Please provide the correct password.'
|
||||
elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]:
|
||||
error_message = 'Incorrect password for this share.'
|
||||
# Check for private scope and recipient
|
||||
elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email:
|
||||
recipients = await file_service.sharing_service._load_share_recipients(share_id)
|
||||
if accessor_email not in recipients:
|
||||
error_message = 'You are not authorized to access this private share.'
|
||||
# Check for account-based scope and anonymous access
|
||||
elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email:
|
||||
error_message = 'This share requires you to be logged in to an authorized account.'
|
||||
|
||||
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
|
||||
'request': request,
|
||||
'error': error_message
|
||||
})
|
||||
|
||||
# If access is verified, record it
|
||||
await file_service.sharing_service.record_share_access(share_id, accessor_email)
|
||||
|
||||
# Proceed with getting the shared item details
|
||||
shared_item = share # Use the already fetched share object
|
||||
|
||||
metadata = await file_service._load_metadata(shared_item['owner_email'])
|
||||
item_meta = metadata.get(shared_item['item_path'])
|
||||
|
||||
if not item_meta:
|
||||
return aiohttp_jinja2.render_template('pages/share_error.html', request, {
|
||||
'request': request,
|
||||
'error': 'The shared item could not be found or has been removed.'
|
||||
})
|
||||
|
||||
is_folder = item_meta.get('type') == 'dir'
|
||||
|
||||
if is_folder:
|
||||
files = await file_service.get_shared_folder_content(share_id, password, accessor_email)
|
||||
return aiohttp_jinja2.render_template('pages/share_folder.html', request, {
|
||||
'request': request,
|
||||
'share_id': share_id,
|
||||
'item_path': shared_item['item_path'],
|
||||
'files': files,
|
||||
'permission': shared_item.get('permission', 'view'),
|
||||
'disable_download': shared_item.get('disable_download', False)
|
||||
})
|
||||
else:
|
||||
return aiohttp_jinja2.render_template('pages/share_file.html', request, {
|
||||
'request': request,
|
||||
'share_id': share_id,
|
||||
'item_path': shared_item['item_path'],
|
||||
'file_name': item_meta.get('name', shared_item['item_path'].split('/')[-1]),
|
||||
'file_size': item_meta.get('size', 0),
|
||||
'permission': shared_item.get('permission', 'view'),
|
||||
'disable_download': shared_item.get('disable_download', False)
|
||||
})
|
||||
|
||||
async def download_shared_file(request: web.Request):
|
||||
share_id = request.match_info['share_id']
|
||||
file_service = request.app['file_service']
|
||||
|
||||
password = request.query.get('password')
|
||||
accessor_email = request.get('user', {}).get('email') if request.get('user') else None
|
||||
requested_file_path = request.query.get('file_path')
|
||||
|
||||
# First, verify access to the share itself
|
||||
share = await file_service.sharing_service.get_share(share_id)
|
||||
if not share:
|
||||
return web.Response(text='Share link is invalid, expired, or deactivated.', status=404)
|
||||
|
||||
if not await file_service.sharing_service.verify_share_access(share_id, password, accessor_email):
|
||||
error_message = 'Access to this share is denied.'
|
||||
if share.get("password_hash") and not password:
|
||||
error_message = 'This share is password protected. Please provide the correct password.'
|
||||
elif share.get("password_hash") and password and file_service.sharing_service._hash_password(password) != share["password_hash"]:
|
||||
error_message = 'Incorrect password for this share.'
|
||||
elif share["scope"] == file_service.sharing_service.SCOPE_PRIVATE and accessor_email:
|
||||
recipients = await file_service.sharing_service._load_share_recipients(share_id)
|
||||
if accessor_email not in recipients:
|
||||
error_message = 'You are not authorized to access this private share.'
|
||||
elif share["scope"] == file_service.sharing_service.SCOPE_ACCOUNT_BASED and not accessor_email:
|
||||
error_message = 'This share requires you to be logged in to an authorized account.'
|
||||
return web.Response(text=error_message, status=403) # Use 403 Forbidden for access denied
|
||||
|
||||
# If access is verified, record it
|
||||
await file_service.sharing_service.record_share_access(share_id, accessor_email)
|
||||
|
||||
# Then attempt to get the file content
|
||||
result = await file_service.get_shared_file_content(share_id, password, accessor_email, requested_file_path)
|
||||
|
||||
if not result:
|
||||
# This means the file itself was not found or download was disabled
|
||||
if share.get("disable_download", False):
|
||||
return web.Response(text='Download is disabled for this share.', status=403)
|
||||
return web.Response(text='The requested file could not be found or has been removed.', status=404)
|
||||
|
||||
content, filename = result
|
||||
|
||||
return web.Response(
|
||||
body=content,
|
||||
headers={
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Content-Disposition': f'attachment; filename="{filename}"'
|
||||
}
|
||||
)
|
||||
|
||||
@login_required
|
||||
@aiohttp_jinja2.template('pages/manage_shares.html')
|
||||
async def manage_shares(request: web.Request):
|
||||
import datetime
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
|
||||
file_service = request.app['file_service']
|
||||
sharing_service = file_service.sharing_service
|
||||
|
||||
shares = await sharing_service.list_user_shares(user_email)
|
||||
|
||||
return {
|
||||
'request': request,
|
||||
'user_email': user_email,
|
||||
'shares': shares,
|
||||
'active_page': 'my_shares',
|
||||
'user': user,
|
||||
'now': datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
@login_required
|
||||
async def get_share_details(request: web.Request):
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
|
||||
share_id = request.match_info['share_id']
|
||||
file_service = request.app['file_service']
|
||||
sharing_service = file_service.sharing_service
|
||||
|
||||
share = await sharing_service.get_share(share_id)
|
||||
|
||||
if not share or share['owner_email'] != user_email:
|
||||
return web.json_response({'error': 'Share not found'}, status=404)
|
||||
|
||||
recipients = await sharing_service.get_share_recipients(share_id)
|
||||
|
||||
return web.json_response({
|
||||
'share': share,
|
||||
'recipients': list(recipients.values())
|
||||
})
|
||||
|
||||
@login_required
|
||||
async def update_share(request: web.Request):
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
|
||||
share_id = request.match_info['share_id']
|
||||
|
||||
try:
|
||||
data = await request.json()
|
||||
except json.JSONDecodeError:
|
||||
return web.json_response({'error': 'Invalid JSON'}, status=400)
|
||||
|
||||
file_service = request.app['file_service']
|
||||
sharing_service = file_service.sharing_service
|
||||
|
||||
action = data.get('action')
|
||||
|
||||
if action == 'update_permission':
|
||||
permission = data.get('permission')
|
||||
success = await sharing_service.update_share_permission(user_email, share_id, permission)
|
||||
|
||||
elif action == 'update_expiration':
|
||||
expiration_days = data.get('expiration_days')
|
||||
success = await sharing_service.update_share_expiration(user_email, share_id, expiration_days)
|
||||
|
||||
elif action == 'deactivate':
|
||||
success = await sharing_service.deactivate_share(user_email, share_id)
|
||||
|
||||
elif action == 'reactivate':
|
||||
success = await sharing_service.reactivate_share(user_email, share_id)
|
||||
|
||||
elif action == 'delete':
|
||||
success = await sharing_service.delete_share(user_email, share_id)
|
||||
|
||||
elif action == 'add_recipient':
|
||||
recipient_email = data.get('recipient_email')
|
||||
permission = data.get('permission', 'view')
|
||||
success = await sharing_service.add_share_recipient(user_email, share_id, recipient_email, permission)
|
||||
|
||||
elif action == 'remove_recipient':
|
||||
recipient_email = data.get('recipient_email')
|
||||
success = await sharing_service.remove_share_recipient(user_email, share_id, recipient_email)
|
||||
|
||||
elif action == 'update_recipient_permission':
|
||||
recipient_email = data.get('recipient_email')
|
||||
permission = data.get('permission')
|
||||
success = await sharing_service.update_recipient_permission(user_email, share_id, recipient_email, permission)
|
||||
|
||||
else:
|
||||
return web.json_response({'error': 'Invalid action'}, status=400)
|
||||
|
||||
if success:
|
||||
return web.json_response({'success': True})
|
||||
else:
|
||||
return web.json_response({'error': 'Operation failed'}, status=400)
|
||||
|
||||
@login_required
|
||||
async def get_item_shares(request: web.Request):
|
||||
user = request['user']
|
||||
user_email = user['email']
|
||||
|
||||
item_path = request.query.get('item_path')
|
||||
if not item_path:
|
||||
return web.json_response({'error': 'item_path is required'}, status=400)
|
||||
|
||||
file_service = request.app['file_service']
|
||||
sharing_service = file_service.sharing_service
|
||||
|
||||
shares = await sharing_service.get_shares_for_item(user_email, item_path)
|
||||
|
||||
return web.json_response({'shares': shares})
|
||||
@ -142,8 +142,22 @@ class SiteView(web.View):
|
||||
|
||||
@login_required
|
||||
async def recent(self):
|
||||
user_email = self.request["user"]["email"]
|
||||
file_service = self.request.app["file_service"]
|
||||
recent_files = await file_service.get_recent_files(user_email)
|
||||
|
||||
# Determine editable and viewable files based on extension
|
||||
editable_extensions = {'.txt', '.md', '.py', '.js', '.html', '.css', '.json', '.xml', '.yaml', '.yml', '.ini', '.cfg', '.log', '.sh', '.bat', '.ps1', '.php', '.rb', '.java', '.c', '.cpp', '.h', '.hpp'}
|
||||
viewable_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg', '.mp4', '.avi', '.mov', '.wmv', '.flv', '.webm', '.mkv', '.mp3', '.wav', '.flac', '.aac', '.ogg', '.m4a'}
|
||||
for item in recent_files:
|
||||
if not item['is_dir']:
|
||||
from pathlib import Path
|
||||
ext = Path(item['name']).suffix.lower()
|
||||
item['is_editable'] = ext in editable_extensions
|
||||
item['is_viewable'] = ext in viewable_extensions
|
||||
|
||||
return aiohttp_jinja2.render_template(
|
||||
"pages/recent.html", self.request, {"request": self.request, "errors": {}, "user": self.request["user"], "active_page": "recent"}
|
||||
"pages/recent.html", self.request, {"request": self.request, "errors": {}, "user": self.request["user"], "active_page": "recent", "recent_files": recent_files}
|
||||
)
|
||||
|
||||
@login_required
|
||||
@ -186,11 +200,13 @@ class FileBrowserView(web.View):
|
||||
# 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")
|
||||
@ -348,6 +364,19 @@ class FileBrowserView(web.View):
|
||||
logger.error(f"FileBrowserView: Failed to generate any share links for user {user_email}")
|
||||
return json_response({"error": "Failed to generate share links for any selected items"}, status=500)
|
||||
|
||||
elif route_name == "toggle_favorite":
|
||||
data = await self.request.json()
|
||||
file_path = data.get("file_path")
|
||||
if not file_path:
|
||||
return json_response({"error": "File path is required"}, status=400)
|
||||
user_service = self.request.app["user_service"]
|
||||
is_fav = await user_service.is_favorite(user_email, file_path)
|
||||
if is_fav:
|
||||
await user_service.remove_favorite(user_email, file_path)
|
||||
else:
|
||||
await user_service.add_favorite(user_email, file_path)
|
||||
return json_response({"is_favorite": not is_fav})
|
||||
|
||||
logger.warning(f"FileBrowserView: Unknown file action for POST request: {route_name}")
|
||||
raise web.HTTPBadRequest(text="Unknown file action")
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user