Merge pull request #155 from gamosoft/features/more-media-support

Features/more media support
This commit is contained in:
Guillermo Villar 2026-01-19 11:19:18 +01:00 committed by GitHub
commit 9496c4fe57
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1036 additions and 563 deletions

View File

@ -45,5 +45,5 @@ HEALTHCHECK --interval=60s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.getenv(\"PORT\", \"8000\")}/health')" CMD python -c "import os, urllib.request; urllib.request.urlopen(f'http://localhost:{os.getenv(\"PORT\", \"8000\")}/health')"
# Run the application (shell form to allow environment variable expansion) # Run the application (shell form to allow environment variable expansion)
CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT CMD uvicorn backend.main:app --host 0.0.0.0 --port $PORT --timeout-graceful-shutdown 2

View File

@ -1,33 +1,56 @@
""" """
HTML Export Module for NoteDiscovery HTML Export Module for NoteDiscovery
Generates standalone HTML files for notes with embedded images and styling. Generates standalone HTML files for notes with embedded images and styling.
Used by both /api/export (download) and /public (sharing) endpoints. Used by both /api/export (download) and /share (public sharing) endpoints.
Note: Only images are embedded as base64. Audio, video, and PDF files are
replaced with placeholder HTML since they would make exports too large.
""" """
import base64 import base64
import re import re
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Tuple
import mimetypes import mimetypes
# Import shared media type definitions from utils to avoid duplication
from backend.utils import MEDIA_EXTENSIONS, get_media_type
def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL.""" def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
if not image_path.exists() or not image_path.is_file(): """
Read a media file and return it as a base64 data URL.
Returns tuple of (base64_url, media_type) or None if failed.
"""
if not media_path.exists() or not media_path.is_file():
return None return None
# Get MIME type # Get MIME type
mime_type, _ = mimetypes.guess_type(str(image_path)) mime_type, _ = mimetypes.guess_type(str(media_path))
if not mime_type or not mime_type.startswith('image/'): if not mime_type:
return None
# Determine media type
media_type = get_media_type(media_path.name)
if not media_type:
return None return None
try: try:
with open(image_path, 'rb') as f: with open(media_path, 'rb') as f:
image_data = f.read() media_data = f.read()
base64_data = base64.b64encode(image_data).decode('utf-8') base64_data = base64.b64encode(media_data).decode('utf-8')
return f"data:{mime_type};base64,{base64_data}" return (f"data:{mime_type};base64,{base64_data}", media_type)
except Exception as e: except Exception as e:
print(f"Failed to read image {image_path}: {e}") print(f"Failed to read media {media_path}: {e}")
return None
# Legacy alias for backward compatibility
def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL."""
result = get_media_as_base64(image_path)
if result and result[1] == 'image':
return result[0]
return None return None
@ -57,22 +80,22 @@ def strip_frontmatter(content: str) -> str:
return '\n'.join(lines[end_idx + 1:]).strip() return '\n'.join(lines[end_idx + 1:]).strip()
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]: def find_media_in_attachments(media_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
""" """
Search for an image file in common attachment locations. Search for a media file in common attachment locations.
Returns the resolved path if found, None otherwise. Returns the resolved path if found, None otherwise.
""" """
# Common locations to search for images (fast path) # Common locations to search for media (fast path)
search_paths = [ search_paths = [
note_folder / image_name, # Same folder as note note_folder / media_name, # Same folder as note
note_folder / '_attachments' / image_name, # Note's _attachments folder note_folder / '_attachments' / media_name, # Note's _attachments folder
notes_dir / '_attachments' / image_name, # Root _attachments folder notes_dir / '_attachments' / media_name, # Root _attachments folder
] ]
# Also search in parent folders' _attachments (for nested notes) # Also search in parent folders' _attachments (for nested notes)
current = note_folder current = note_folder
while current != notes_dir and current.parent != current: while current != notes_dir and current.parent != current:
search_paths.append(current / '_attachments' / image_name) search_paths.append(current / '_attachments' / media_name)
current = current.parent current = current.parent
for path in search_paths: for path in search_paths:
@ -86,11 +109,11 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
continue continue
# Fallback: search all _attachments folders recursively (slower but thorough) # Fallback: search all _attachments folders recursively (slower but thorough)
# This handles cross-folder image references like in Obsidian # This handles cross-folder media references like in Obsidian
try: try:
for attachment_folder in notes_dir.rglob('_attachments'): for attachment_folder in notes_dir.rglob('_attachments'):
if attachment_folder.is_dir(): if attachment_folder.is_dir():
candidate = attachment_folder / image_name candidate = attachment_folder / media_name
if candidate.exists() and candidate.is_file(): if candidate.exists() and candidate.is_file():
try: try:
candidate.resolve().relative_to(notes_dir.resolve()) candidate.resolve().relative_to(notes_dir.resolve())
@ -103,63 +126,125 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
return None return None
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str: # Legacy alias
def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Path) -> Optional[Path]:
return find_media_in_attachments(image_name, note_folder, notes_dir)
def generate_media_placeholder(media_type: str, alt_text: str) -> str:
"""Generate a placeholder for non-embeddable media (audio, video, PDF)."""
safe_alt = alt_text.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
icons = {'audio': '🎵', 'video': '🎬', 'document': '📄'}
labels = {'audio': 'Audio file', 'video': 'Video file', 'document': 'PDF document'}
icon = icons.get(media_type, '📎')
label = labels.get(media_type, 'Media file')
return f'''<div style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;display:flex;align-items:center;gap:1rem;">
<span style="font-size:2rem;">{icon}</span>
<div>
<div style="font-weight:600;color:var(--text-primary,#212529);">{safe_alt}</div>
<div style="font-size:0.875rem;color:var(--text-secondary,#6c757d);">{label} not available in exported view</div>
</div>
</div>'''
def process_media_for_export(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
""" """
Find all image references in markdown and embed them as base64. Process all media references in markdown for standalone HTML export.
Handles: Handles:
- Standard markdown images: ![alt](path) - Standard markdown images: ![alt](path)
- Wikilink images: ![[image.png]] or ![[image.png|alt text]] - Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
Behavior by media type:
- Images (jpg, png, gif, webp): Embedded as base64 data URLs
- Audio/Video/PDF: Replaced with styled placeholder HTML (not embedded - too large)
""" """
# First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]] # First, handle wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]' wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink_image(match): def replace_wikilink_media(match):
image_name = match.group(1).strip() media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0] alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
# Find the image # Check media type first
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) media_type = get_media_type(media_name)
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, alt_text)
# For images, embed as base64
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if resolved_path: if resolved_path:
base64_url = get_image_as_base64(resolved_path) base64_url = get_image_as_base64(resolved_path)
if base64_url: if base64_url:
return f'![{alt_text}]({base64_url})' return f'![{alt_text}]({base64_url})'
# Image not found, convert to placeholder # Image not found
return f'![{alt_text}]()' return f'<span style="color:var(--text-tertiary,#999);opacity:0.7;" title="Image not found">🖼️ {alt_text}</span>'
markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content) markdown_content = re.sub(wikilink_pattern, replace_wikilink_media, markdown_content)
# Then, handle standard markdown images: ![alt](path) # Then, handle standard markdown images: ![alt](path)
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)' img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
def replace_image(match): def replace_media(match):
alt_text = match.group(1) alt_text = match.group(1)
img_path = match.group(2) media_path = match.group(2)
# Skip external URLs and already-embedded base64 # Handle external URLs
if img_path.startswith(('http://', 'https://', 'data:')): if media_path.startswith(('http://', 'https://')):
# Check if it's a PDF - generate styled external link
media_type = get_media_type(media_path)
if media_type == 'document':
display_name = alt_text or Path(media_path).stem
safe_name = display_name.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')
safe_url = media_path.replace('"', '&quot;')
return f'''<a href="{safe_url}" target="_blank" rel="noopener noreferrer" style="display:flex;flex-direction:column;gap:0.25rem;padding:1rem 1.25rem;margin:1rem 0;background:linear-gradient(135deg,var(--bg-tertiary,#f8f9fa) 0%,var(--bg-secondary,#e9ecef) 100%);border:1px solid var(--border-primary,#dee2e6);border-radius:0.5rem;color:var(--text-primary,#212529);text-decoration:none;">
<span style="font-weight:600;">📄 {safe_name}</span>
<span style="font-size:0.75rem;color:var(--text-secondary,#6c757d);">Opens in new tab</span>
</a>'''
# Other external media: keep as-is (will show as broken image)
return match.group(0)
# Skip already-embedded base64
if media_path.startswith('data:'):
return match.group(0) return match.group(0)
# Skip empty paths (from failed wikilink conversion) # Skip empty paths (from failed wikilink conversion)
if not img_path: if not media_path:
return match.group(0) return match.group(0)
# Handle /api/images/ paths (convert to filesystem paths) # Check media type first
if img_path.startswith('/api/images/'): media_type = get_media_type(media_path)
# Strip the /api/images/ prefix to get the relative path within notes_dir display_alt = alt_text or Path(media_path).stem
relative_path = img_path[len('/api/images/'):]
# For non-image media (audio, video, PDF), show placeholder without embedding
if media_type in ('audio', 'video', 'document'):
return generate_media_placeholder(media_type, display_alt)
# For images, proceed with base64 embedding
# Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if media_path.startswith('/api/media/'):
relative_path = media_path[len('/api/media/'):]
resolved_path = (notes_dir / relative_path).resolve()
elif media_path.startswith('/api/images/'):
# Legacy path support for backward compatibility
relative_path = media_path[len('/api/images/'):]
resolved_path = (notes_dir / relative_path).resolve() resolved_path = (notes_dir / relative_path).resolve()
else: else:
# Try to resolve the image path relative to note folder # Try to resolve the media path relative to note folder
resolved_path = (note_folder / img_path).resolve() resolved_path = (note_folder / media_path).resolve()
# If not found, try the attachment search # If not found, try the attachment search
if not resolved_path.exists(): if not resolved_path.exists():
# Extract just the filename and search # Extract just the filename and search
image_name = Path(img_path).name media_name = Path(media_path).name
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir) resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if not resolved_path: if not resolved_path:
return match.group(0) # Keep original if not found return match.group(0) # Keep original if not found
@ -170,19 +255,26 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir:
# Path is outside notes_dir, skip # Path is outside notes_dir, skip
return match.group(0) return match.group(0)
# Get base64 data # Get base64 data for image
base64_url = get_image_as_base64(resolved_path) base64_url = get_image_as_base64(resolved_path)
if base64_url: if base64_url:
return f'![{alt_text}]({base64_url})' return f'![{display_alt}]({base64_url})'
# Image not found, keep original # Image not found, keep original
return match.group(0) return match.group(0)
markdown_content = re.sub(img_pattern, replace_image, markdown_content) markdown_content = re.sub(img_pattern, replace_media, markdown_content)
return markdown_content return markdown_content
# Legacy alias for backward compatibility
# Legacy alias for backward compatibility
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""Alias for process_media_for_export (legacy name kept for compatibility)."""
return process_media_for_export(markdown_content, note_folder, notes_dir)
def convert_wikilinks_to_html(markdown_content: str) -> str: def convert_wikilinks_to_html(markdown_content: str) -> str:
""" """
Convert wikilinks [[note]] or [[note|display text]] to HTML links. Convert wikilinks [[note]] or [[note|display text]] to HTML links.

View File

@ -476,90 +476,162 @@ async def create_new_folder(request: Request, data: dict):
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to create folder"))
@api_router.get("/images/{image_path:path}", tags=["Images"]) @api_router.get("/media/{media_path:path}", tags=["Media"])
async def get_image(image_path: str): async def get_media(media_path: str):
""" """
Serve an image file with authentication protection. Serve a media file (image, audio, video, PDF) with authentication protection.
""" """
try: try:
from backend.utils import ALL_MEDIA_EXTENSIONS
notes_dir = config['storage']['notes_dir'] notes_dir = config['storage']['notes_dir']
full_path = Path(notes_dir) / image_path full_path = Path(notes_dir) / media_path
# Security: Validate path is within notes directory # Security: Validate path is within notes directory
if not validate_path_security(notes_dir, full_path): if not validate_path_security(notes_dir, full_path):
raise HTTPException(status_code=403, detail="Access denied") raise HTTPException(status_code=403, detail="Access denied")
# Check file exists and is an image # Check file exists
if not full_path.exists() or not full_path.is_file(): if not full_path.exists() or not full_path.is_file():
raise HTTPException(status_code=404, detail="Image not found") raise HTTPException(status_code=404, detail="File not found")
# Validate it's an image file # Validate it's an allowed media file
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
if full_path.suffix.lower() not in allowed_extensions: raise HTTPException(status_code=400, detail="Not an allowed media file")
raise HTTPException(status_code=400, detail="Not an image file")
# Return the file # Return the file
return FileResponse(full_path) return FileResponse(full_path)
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load image")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file"))
@api_router.post("/upload-image", tags=["Images"]) @api_router.post("/upload-media", tags=["Media"])
@limiter.limit("20/minute") @limiter.limit("20/minute")
async def upload_image(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)):
""" """
Upload an image file and save it to the attachments directory. Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
Returns the relative path to the image for markdown linking. Returns the relative path for markdown linking.
""" """
try: try:
# Validate file type from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} # Allowed MIME types for each category
allowed_types = {
# Images
'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp',
# Audio
'audio/mpeg', 'audio/mp3', 'audio/wav', 'audio/ogg', 'audio/m4a', 'audio/x-m4a',
# Video
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo',
# Documents
'application/pdf',
}
# Get file extension # Get file extension
file_ext = Path(file.filename).suffix.lower() if file.filename else '' file_ext = Path(file.filename).suffix.lower() if file.filename else ''
if file.content_type not in allowed_types and file_ext not in allowed_extensions: if file.content_type not in allowed_types and file_ext not in ALL_MEDIA_EXTENSIONS:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"Invalid file type. Allowed: jpg, jpeg, png, gif, webp. Got: {file.content_type}" detail=f"Invalid file type. Allowed: images, audio (mp3), video (mp4), PDF. Got: {file.content_type}"
) )
# Read file data # Read file data
file_data = await file.read() file_data = await file.read()
# Validate file size (10MB max) # Validate file size - different limits for different types
max_size = 10 * 1024 * 1024 # 10MB in bytes media_type = get_media_type(file.filename) if file.filename else None
# Size limits: images 10MB, audio 50MB, video 100MB, PDF 20MB
size_limits = {
'image': 10 * 1024 * 1024,
'audio': 50 * 1024 * 1024,
'video': 100 * 1024 * 1024,
'document': 20 * 1024 * 1024,
}
max_size = size_limits.get(media_type, 10 * 1024 * 1024)
if len(file_data) > max_size: if len(file_data) > max_size:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail=f"File too large. Maximum size: 10MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB" detail=f"File too large. Maximum size for {media_type or 'this type'}: {max_size // (1024*1024)}MB. Uploaded: {len(file_data) / 1024 / 1024:.2f}MB"
) )
# Save the image # Save the file (reusing image save function - it works for any file)
image_path = save_uploaded_image( file_path = save_uploaded_image(
config['storage']['notes_dir'], config['storage']['notes_dir'],
note_path, note_path,
file.filename, file.filename,
file_data file_data
) )
if not image_path: if not file_path:
raise HTTPException(status_code=500, detail="Failed to save image") raise HTTPException(status_code=500, detail="Failed to save file")
return { return {
"success": True, "success": True,
"path": image_path, "path": file_path,
"filename": Path(image_path).name, "filename": Path(file_path).name,
"message": "Image uploaded successfully" "type": media_type,
"message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
} }
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload image")) raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to upload file"))
@api_router.post("/media/move", tags=["Media"])
@limiter.limit("30/minute")
async def move_media_endpoint(request: Request, data: dict):
"""Move a media file to a different folder"""
try:
from backend.utils import ALL_MEDIA_EXTENSIONS
old_path = data.get('oldPath', '')
new_path = data.get('newPath', '')
if not old_path or not new_path:
raise HTTPException(status_code=400, detail="Both oldPath and newPath required")
notes_dir = config['storage']['notes_dir']
old_full_path = Path(notes_dir) / old_path
new_full_path = Path(notes_dir) / new_path
# Security: Validate paths are within notes directory
if not validate_path_security(notes_dir, old_full_path):
raise HTTPException(status_code=403, detail="Invalid source path")
if not validate_path_security(notes_dir, new_full_path):
raise HTTPException(status_code=403, detail="Invalid destination path")
# Validate it's a media file
if old_full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not a valid media file")
# Check source exists
if not old_full_path.exists():
raise HTTPException(status_code=404, detail=f"Media file not found: {old_path}")
# Check target doesn't exist
if new_full_path.exists():
raise HTTPException(status_code=409, detail=f"A file already exists at: {new_path}")
# Create parent directory if needed
new_full_path.parent.mkdir(parents=True, exist_ok=True)
# Move the file
import shutil
shutil.move(str(old_full_path), str(new_full_path))
return {"success": True, "message": "Media moved successfully", "newPath": new_path}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to move media file"))
@api_router.post("/notes/move", tags=["Notes"]) @api_router.post("/notes/move", tags=["Notes"])

View File

@ -486,38 +486,63 @@ def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data
return None return None
# Media file type definitions
MEDIA_EXTENSIONS = {
'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'},
'audio': {'.mp3', '.wav', '.ogg', '.m4a'},
'video': {'.mp4', '.webm', '.mov', '.avi'},
'document': {'.pdf'},
}
# All supported media extensions (flat set for quick lookup)
ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values())
def get_media_type(filename: str) -> Optional[str]:
"""
Determine the media type based on file extension.
Returns: 'image', 'audio', 'video', 'document', or None if not a media file.
"""
ext = Path(filename).suffix.lower()
for media_type, extensions in MEDIA_EXTENSIONS.items():
if ext in extensions:
return media_type
return None
def get_all_images(notes_dir: str) -> List[Dict]: def get_all_images(notes_dir: str) -> List[Dict]:
""" """
Get all images from attachments directories. Get all media files (images, audio, video, documents) from anywhere in the notes directory.
Returns list of image dictionaries with metadata. Returns list of media dictionaries with metadata.
Note: Function name kept as 'get_all_images' for backward compatibility.
""" """
images = [] media_files = []
notes_path = Path(notes_dir) notes_path = Path(notes_dir)
# Common image extensions # Find all media files recursively in the entire notes directory
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'} for media_file in notes_path.rglob("*"):
# Skip directories and hidden files/folders
# Find all attachments directories if media_file.is_dir():
for attachments_dir in notes_path.rglob("_attachments"): continue
if not attachments_dir.is_dir(): if any(part.startswith('.') for part in media_file.parts):
continue continue
# Find all images in this attachments directory # Check if it's a media file
for image_file in attachments_dir.iterdir(): media_type = get_media_type(media_file.name)
if image_file.is_file() and image_file.suffix.lower() in image_extensions: if media_type:
relative_path = image_file.relative_to(notes_path) relative_path = media_file.relative_to(notes_path)
stat = image_file.stat() stat = media_file.stat()
images.append({ media_files.append({
"name": image_file.name, "name": media_file.name,
"path": str(relative_path.as_posix()), "path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()), "folder": str(relative_path.parent.as_posix()) if relative_path.parent != Path('.') else "",
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), "modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"size": stat.st_size, "size": stat.st_size,
"type": "image" "type": media_type # 'image', 'audio', 'video', or 'document'
}) })
return images return media_files
def parse_tags(content: str) -> List[str]: def parse_tags(content: str) -> List[str]:

View File

@ -81,66 +81,93 @@ Content-Type: application/json
} }
``` ```
## 🖼️ Images ## 🎬 Media
### Get Image ### Get Media
```http ```http
GET /api/images/{image_path} GET /api/media/{media_path}
``` ```
Retrieve an image file with authentication protection. Retrieve a media file (image, audio, video, PDF) with authentication protection.
**Example:** **Example:**
```bash ```bash
curl http://localhost:8000/api/images/folder/_attachments/image-20240417093343.png curl http://localhost:8000/api/media/folder/_attachments/image-20240417093343.png
``` ```
**Security Note:** This endpoint requires authentication and validates that: **Security Note:** This endpoint requires authentication and validates that:
- The image path is within the notes directory (prevents directory traversal) - The media path is within the notes directory (prevents directory traversal)
- The file exists and is a valid image format - The file exists and is a valid media format
- The requesting user is authenticated (if auth is enabled) - The requesting user is authenticated (if auth is enabled)
### Upload Image ### Upload Media
```http ```http
POST /api/upload-image POST /api/upload-media
Content-Type: multipart/form-data Content-Type: multipart/form-data
file: <image file> file: <media file>
note_path: <path of note to attach to> note_path: <path of note to attach to>
``` ```
Upload an image file to the `_attachments` directory. Images are automatically organized per-folder and named with timestamps to prevent conflicts. Upload a media file to the `_attachments` directory. Files are automatically organized per-folder and named with timestamps to prevent conflicts.
**Supported formats:** JPG, JPEG, PNG, GIF, WEBP **Supported formats & size limits:**
**Maximum size:** 10MB | Type | Formats | Max Size |
|------|---------|----------|
| Images | JPG, PNG, GIF, WebP | 10 MB |
| Audio | MP3, WAV, OGG, M4A | 50 MB |
| Video | MP4, WebM, MOV, AVI | 100 MB |
| Documents | PDF | 20 MB |
**Response:** **Response:**
```json ```json
{ {
"success": true, "success": true,
"path": "folder/_attachments/image-20240417093343.png", "path": "folder/_attachments/media-20240417093343.png",
"filename": "image-20240417093343.png", "filename": "media-20240417093343.png",
"message": "Image uploaded successfully" "message": "Media uploaded successfully"
} }
``` ```
**Example (using curl):** **Example (using curl):**
```bash ```bash
curl -X POST http://localhost:8000/api/upload-image \ curl -X POST http://localhost:8000/api/upload-media \
-F "file=@/path/to/image.png" \ -F "file=@/path/to/file.mp3" \
-F "note_path=folder/mynote.md" -F "note_path=folder/mynote.md"
``` ```
**Windows PowerShell:** **Windows PowerShell:**
```powershell ```powershell
curl.exe -X POST http://localhost:8000/api/upload-image -F "file=@C:\path\to\image.png" -F "note_path=folder/mynote.md" curl.exe -X POST http://localhost:8000/api/upload-media -F "file=@C:\path\to\video.mp4" -F "note_path=folder/mynote.md"
```
### Move Media
```http
POST /api/media/move
Content-Type: application/json
{
"oldPath": "_attachments/image.png",
"newPath": "folder/_attachments/image.png"
}
```
Move a media file to a different location. Supports drag & drop in the UI.
**Response:**
```json
{
"success": true,
"message": "Media moved successfully",
"newPath": "folder/_attachments/image.png"
}
``` ```
**Notes:** **Notes:**
- Images are stored in `_attachments` folders relative to the note's location - Media is stored in `_attachments` folders relative to the note's location
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`) - Filenames are automatically timestamped (e.g., `media-20240417093343.mp3`)
- Images appear in the sidebar navigation and can be viewed/deleted directly - Media appears in the sidebar navigation and can be viewed/deleted directly
- Drag & drop images into the editor automatically uploads and inserts markdown - Drag & drop files into the editor automatically uploads and inserts markdown
- All image access requires authentication when security is enabled - All media access requires authentication when security is enabled
## 📁 Folders ## 📁 Folders

View File

@ -15,10 +15,15 @@
- **HTML Export** - Export notes as standalone HTML files with embedded images - **HTML Export** - Export notes as standalone HTML files with embedded images
- **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md)) - **Public Sharing** - Share notes via token-based URLs without requiring authentication (see [SHARING.md](SHARING.md))
### Image Support ### Media Support
- **Drag & drop upload** - Drop images from your file system directly into the editor - **Drag & drop upload** - Drop files from your file system directly into the editor
- **Clipboard paste** - Paste images from clipboard with Ctrl+V - **Clipboard paste** - Paste images from clipboard with Ctrl+V
- **Multiple formats** - Supports JPG, PNG, GIF, and WebP (max 10MB) - **Images** - JPG, PNG, GIF, WebP (max 10MB)
- **Audio** - MP3, WAV, OGG, M4A (max 50MB)
- **Video** - MP4, WebM, MOV, AVI (max 100MB)
- **Documents** - PDF (max 20MB)
- **In-app viewing** - View all media types directly in the sidebar
- **Inline preview** - Audio/video players and PDF viewer embedded in notes
### Organization ### Organization
- **Folder hierarchy** - Organize notes in nested folders - **Folder hierarchy** - Organize notes in nested folders

File diff suppressed because it is too large Load Diff

View File

@ -444,6 +444,92 @@
margin: 1rem 0; margin: 1rem 0;
} }
/* Embedded Media (audio, video, PDF) */
.markdown-preview .media-embed {
margin: 1.5rem 0;
border-radius: 0.5rem;
overflow: hidden;
}
/* Audio embeds */
.markdown-preview .media-audio {
display: flex;
flex-direction: column;
align-items: center;
padding: 1.5rem;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-primary);
}
.markdown-preview .media-audio audio {
width: 100%;
max-width: 500px;
border-radius: 2rem;
}
.markdown-preview .media-audio .media-caption {
margin-top: 0.75rem;
font-size: 0.875rem;
color: var(--text-secondary);
}
/* Video embeds */
.markdown-preview .media-video {
background: #000;
display: flex;
justify-content: center;
}
.markdown-preview .media-video video {
width: 100%;
max-width: 800px;
max-height: 450px;
border-radius: 0;
}
/* PDF embeds (iframe) */
.markdown-preview .media-pdf {
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
overflow: hidden;
background: var(--bg-secondary);
}
.markdown-preview .media-pdf iframe {
width: 100%;
height: 600px;
border: none;
display: block;
}
/* PDF links (for external PDFs) */
.markdown-preview .pdf-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 1rem 1.25rem;
margin: 1rem 0;
background: linear-gradient(135deg, var(--bg-tertiary) 0%, var(--bg-secondary) 100%);
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
color: var(--text-primary);
text-decoration: none;
transition: all 0.15s ease;
}
.markdown-preview .pdf-link:hover {
background: var(--bg-secondary);
border-color: var(--accent-primary);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.markdown-preview .pdf-link:hover .pdf-link-content {
color: var(--accent-primary);
}
.markdown-preview .pdf-link-content {
font-weight: 600;
color: var(--text-primary);
}
.markdown-preview .pdf-link-note {
font-size: 0.75rem;
color: var(--text-secondary);
}
/* Task lists */ /* Task lists */
.markdown-preview input[type="checkbox"] { .markdown-preview input[type="checkbox"] {
margin-right: 0.5rem; margin-right: 0.5rem;
@ -849,6 +935,12 @@
font-size: 10px; font-size: 10px;
} }
/* Zen mode mobile adjustments - use 95% width instead of fixed padding */
.zen-mode-active .zen-editor-container {
padding: 1rem 2.5%;
width: 100%;
}
} }
@media (min-width: 769px) { @media (min-width: 769px) {
@ -1377,7 +1469,7 @@
<svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24"> <svg class="w-3.5 h-3.5 flex-shrink-0" style="color: var(--warning, #eab308);" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"></path>
</svg> </svg>
<span class="text-xs truncate" x-text="fav.name"></span> <span class="text-xs truncate" x-text="fav.name" :title="fav.name"></span>
</div> </div>
<button <button
@click.stop="toggleFavorite(fav.path)" @click.stop="toggleFavorite(fav.path)"
@ -1417,24 +1509,24 @@
<div <div
class="text-xs px-2 py-1 mb-2 rounded transition-all font-medium text-center" class="text-xs px-2 py-1 mb-2 rounded transition-all font-medium text-center"
:class="{ :class="{
'border-2 border-dashed bg-accent-light': (draggedNote || draggedFolder) && dragOverFolder === '', 'border-2 border-dashed bg-accent-light': draggedItem && dragOverFolder === '',
'border-2 border-dashed': (draggedNote || draggedFolder) && dragOverFolder !== '', 'border-2 border-dashed': draggedItem && dragOverFolder !== '',
'border-2 border-transparent': !draggedNote && !draggedFolder 'border-2 border-transparent': !draggedItem
}" }"
:style="{ :style="{
'color': (draggedNote || draggedFolder) ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)', 'color': draggedItem ? (dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--text-secondary)') : 'var(--text-tertiary)',
'opacity': (draggedNote || draggedFolder) ? '1' : '0.8', 'opacity': draggedItem ? '1' : '0.8',
'border-color': (draggedNote || draggedFolder) && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)', 'border-color': draggedItem && dragOverFolder === '' ? 'var(--accent-primary)' : 'var(--border-secondary)',
'background-color': dragOverFolder === '' ? 'var(--accent-light)' : '' 'background-color': dragOverFolder === '' ? 'var(--accent-light)' : ''
}" }"
@dragover.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';" @dragover.prevent="if(draggedItem) dragOverFolder = '';"
@dragenter.prevent="if(draggedNote || draggedFolder) dragOverFolder = '';" @dragenter.prevent="if(draggedItem) dragOverFolder = '';"
@dragleave="if(draggedNote || draggedFolder) dragOverFolder = null;" @dragleave="if(draggedItem) dragOverFolder = null;"
@drop="if(draggedNote || draggedFolder) onFolderDrop('')" @drop="if(draggedItem) onFolderDrop('')"
style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;" style="height: 28px; line-height: 20px; display: flex; align-items: center; justify-content: center;"
> >
<span x-show="!draggedNote && !draggedFolder && !draggedItem" x-text="t('sidebar.drag_hint')"></span> <span x-show="!draggedItem" x-text="t('sidebar.drag_hint')"></span>
<span x-show="draggedNote || draggedFolder" x-text="t('sidebar.root_folder')"></span> <span x-show="draggedItem" x-text="t('sidebar.root_folder')"></span>
</div> </div>
</div> </div>
@ -1443,34 +1535,8 @@
<div x-html="renderFolderRecursive(folder, 0, true)"></div> <div x-html="renderFolderRecursive(folder, 0, true)"></div>
</template> </template>
<!-- Root notes and images (no folder) - shown after folders --> <!-- Root notes and images (no folder) - rendered via JS for consistency -->
<template x-for="note in (folderTree.__root__ && folderTree.__root__.notes ? folderTree.__root__.notes : [])" :key="note.path"> <div x-html="renderRootItems()"></div>
<div
draggable="true"
@dragstart="onNoteDragStart(note.path, $event)"
@dragend="onNoteDragEnd()"
@click="openItem(note.path, note.type)"
class="note-item px-2 py-1 text-sm relative"
:style="((note.type === 'image' ? currentImage === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type === 'image' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
@mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'"
>
<span class="truncate" style="display: block; padding-right: 30px;">
<template x-if="note.type === 'image'">🖼️ </template>
<span x-text="note.name"></span>
</span>
<button
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)"
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
style="opacity: 0; color: var(--error);"
:title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</template>
<!-- Empty state --> <!-- Empty state -->
<template x-if="notes.length === 0 && allFolders.length === 0"> <template x-if="notes.length === 0 && allFolders.length === 0">
@ -1550,8 +1616,8 @@
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'" onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'" onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
> >
<div class="font-medium truncate" x-text="note.name"></div> <div class="font-medium truncate" x-text="note.name" :title="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')"></div> <div class="text-xs truncate" style="color: var(--text-tertiary);" x-html="(note.matches && note.matches.length > 0) ? note.matches[0].context : (note.folder || 'Root')" :title="note.folder || 'Root'"></div>
</div> </div>
</template> </template>
</div> </div>
@ -1646,7 +1712,7 @@
onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'" onmouseover="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='var(--bg-hover)'"
onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'" onmouseout="if(this.style.backgroundColor !== 'var(--accent-light)') this.style.backgroundColor='transparent'"
> >
<span class="truncate block" x-text="note.name"></span> <span class="truncate block" x-text="note.name" :title="note.name"></span>
</div> </div>
</template> </template>
<div x-show="searchResults.length === 0" class="px-3 py-4 text-center text-xs" style="color: var(--text-tertiary);" x-text="t('tags.no_matches')"> <div x-show="searchResults.length === 0" class="px-3 py-4 text-center text-xs" style="color: var(--text-tertiary);" x-text="t('tags.no_matches')">
@ -1684,6 +1750,7 @@
class="block truncate" class="block truncate"
:style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')" :style="heading.level === 1 ? 'font-weight: 600;' : (heading.level === 2 ? 'font-weight: 500;' : '')"
x-text="heading.text" x-text="heading.text"
:title="heading.text"
></span> ></span>
</button> </button>
</template> </template>
@ -1868,7 +1935,7 @@
</button> </button>
</div> </div>
<template x-if="!currentNote && !currentImage"> <template x-if="!currentNote && !currentMedia">
<!-- Notes Homepage --> <!-- Notes Homepage -->
<div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);"> <div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);">
<!-- Mobile Menu Button (Homepage) --> <!-- Mobile Menu Button (Homepage) -->
@ -2044,12 +2111,12 @@
> >
<!-- Delete Button --> <!-- Delete Button -->
<button <button
@click.stop="note.type === 'image' ? deleteImage(note.path) : deleteNote(note.path, note.name)" @click.stop="note.type !== 'note' ? deleteMedia(note.path) : deleteNote(note.path, note.name)"
class="card-delete-btn hidden sm:block" class="card-delete-btn hidden sm:block"
style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);" style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'" onmouseout="this.style.backgroundColor='var(--bg-secondary)'"
:title="note.type === 'image' ? t('toolbar.delete_image') : t('toolbar.delete_note')" :title="note.type !== 'note' ? t('toolbar.delete_image') : t('toolbar.delete_note')"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2080,7 +2147,7 @@
</div> </div>
</template> </template>
<template x-if="currentNote || currentImage"> <template x-if="currentNote || currentMedia">
<!-- Editor Area --> <!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);"> <div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
<!-- Toolbar --> <!-- Toolbar -->
@ -2101,7 +2168,7 @@
</button> </button>
<input <input
type="text" type="text"
:value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')" :value="currentNote ? currentNoteName : (currentMedia ? currentMedia.split('/').pop() : '')"
@input="if (currentNote) currentNoteName = $event.target.value" @input="if (currentNote) currentNoteName = $event.target.value"
@blur="if (currentNote) renameNote()" @blur="if (currentNote) renameNote()"
:readonly="!currentNote" :readonly="!currentNote"
@ -2140,12 +2207,12 @@
<!-- Delete Button --> <!-- Delete Button -->
<button <button
@click="currentImage ? deleteImage(currentImage) : deleteCurrentNote()" @click="currentMedia ? deleteMedia(currentMedia) : deleteCurrentNote()"
class="p-2 rounded-lg" class="p-2 rounded-lg"
style="color: var(--error);" style="color: var(--error);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
:title="currentImage ? t('toolbar.delete_image') : t('toolbar.delete_note')" :title="currentMedia ? t('toolbar.delete_image') : t('toolbar.delete_note')"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2287,7 +2354,7 @@
<div class="flex-1 flex relative" style="min-height: 0;"> <div class="flex-1 flex relative" style="min-height: 0;">
<!-- Editor --> <!-- Editor -->
<div <div
x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')" x-show="!currentMedia && (viewMode === 'edit' || viewMode === 'split')"
class="flex flex-col" class="flex flex-col"
style="min-height: 0;" style="min-height: 0;"
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'" :style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
@ -2366,7 +2433,7 @@
<!-- Split view resize handle --> <!-- Split view resize handle -->
<div <div
x-show="!currentImage && viewMode === 'split'" x-show="!currentMedia && viewMode === 'split'"
@mousedown="startSplitResize($event)" @mousedown="startSplitResize($event)"
class="split-resize-handle" class="split-resize-handle"
style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;" style="width: 6px; cursor: col-resize; background: linear-gradient(90deg, transparent 0%, var(--border-secondary) 50%, transparent 100%); transition: all 0.2s; position: relative; z-index: 10; opacity: 0.5;"
@ -2376,7 +2443,7 @@
<!-- Preview --> <!-- Preview -->
<div <div
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage" x-show="(viewMode === 'preview' || viewMode === 'split') && !currentMedia"
class="overflow-y-auto overflow-x-hidden custom-scrollbar" class="overflow-y-auto overflow-x-hidden custom-scrollbar"
style="background-color: var(--bg-primary); min-height: 0;" style="background-color: var(--bg-primary); min-height: 0;"
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'" :style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
@ -2499,17 +2566,55 @@
></div> ></div>
</div> </div>
<!-- Image Viewer --> <!-- Media Viewer (images, audio, video, PDF) -->
<template x-if="currentImage"> <template x-if="currentMedia">
<div <div
class="flex-1 flex items-center justify-center overflow-auto" class="flex-1 flex items-center justify-center overflow-auto"
style="background-color: var(--bg-primary); min-height: 0;" style="background-color: var(--bg-primary); min-height: 0;"
> >
<!-- Image -->
<template x-if="currentMediaType === 'image'">
<img <img
:src="`/api/images/${currentImage}`" :src="`/api/media/${currentMedia}`"
:alt="currentImage.split('/').pop()" :alt="currentMedia.split('/').pop()"
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;" style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;"
/> />
</template>
<!-- Audio -->
<template x-if="currentMediaType === 'audio'">
<div class="flex flex-col items-center gap-4 p-8">
<div class="text-6xl">🎵</div>
<div class="text-lg font-medium" style="color: var(--text-primary);" x-text="currentMedia.split('/').pop()"></div>
<audio
controls
:src="`/api/media/${currentMedia}`"
style="width: 100%; max-width: 500px;"
>
Your browser does not support the audio element.
</audio>
</div>
</template>
<!-- Video -->
<template x-if="currentMediaType === 'video'">
<video
controls
:src="`/api/media/${currentMedia}`"
style="max-width: 100%; max-height: 100%; padding: 1rem;"
>
Your browser does not support the video element.
</video>
</template>
<!-- PDF -->
<template x-if="currentMediaType === 'document'">
<iframe
:src="`/api/media/${currentMedia}`"
style="width: 100%; height: 100%; border: none;"
:title="currentMedia.split('/').pop()"
></iframe>
</template>
</div> </div>
</template> </template>
</div> </div>
@ -2836,8 +2941,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg> </svg>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name"></div> <div class="font-medium truncate" style="color: var(--text-primary);" x-text="note.name" :title="note.name"></div>
<div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder"></div> <div class="text-xs truncate" style="color: var(--text-secondary);" x-show="note.folder" x-text="note.folder" :title="note.folder"></div>
</div> </div>
</div> </div>
</template> </template>
@ -2958,18 +3063,18 @@
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation"> <nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
<button <button
class="mobile-bottom-tab" class="mobile-bottom-tab"
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentImage && !showGraph))}" :class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentMedia && !showGraph))}"
@click="mobileFilesTabClick()" @click="mobileFilesTabClick()"
:aria-label="t('sidebar.files')" :aria-label="t('sidebar.files')"
> >
<!-- Show home icon when viewing content, folder icon when on homepage --> <!-- Show home icon when viewing content, folder icon when on homepage -->
<svg x-show="currentNote || currentImage || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg x-show="currentNote || currentMedia || showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg> </svg>
<svg x-show="!currentNote && !currentImage && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"> <svg x-show="!currentNote && !currentMedia && !showGraph" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
</svg> </svg>
<span x-text="(currentNote || currentImage || showGraph) ? t('homepage.title') : t('sidebar.files')"></span> <span x-text="(currentNote || currentMedia || showGraph) ? t('homepage.title') : t('sidebar.files')"></span>
</button> </button>
<button <button
class="mobile-bottom-tab relative" class="mobile-bottom-tab relative"

View File

@ -44,6 +44,7 @@
"no_notes": "Du hast noch keine Notizen. Erstelle deine erste!", "no_notes": "Du hast noch keine Notizen. Erstelle deine erste!",
"no_notes_yet": "Noch keine Notizen oder Ordner.", "no_notes_yet": "Noch keine Notizen oder Ordner.",
"create_first": "Erstelle deine erste Notiz oder Ordner!", "create_first": "Erstelle deine erste Notiz oder Ordner!",
"drop_to_root": "Hier ablegen für Stammverzeichnis",
"no_favorites": "Noch keine Favoriten", "no_favorites": "Noch keine Favoriten",
"no_results": "Keine Ergebnisse gefunden", "no_results": "Keine Ergebnisse gefunden",
"expand_all": "Alle Ordner aufklappen", "expand_all": "Alle Ordner aufklappen",
@ -194,16 +195,17 @@
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut." "error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
}, },
"images": { "media": {
"confirm_delete": "Bild \"{{name}}\" löschen?", "confirm_delete": "\"{{name}}\" löschen?",
"upload_failed": "Bild-Upload fehlgeschlagen", "upload_failed": "Datei-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Bilddateien gefunden. Unterstützte Formate: JPG, PNG, GIF, WEBP", "no_valid_files": "Keine gültigen Dateien gefunden. Unterstützt: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Unterstützt JPG, PNG, GIF, WebP (max 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Verschieben der Notiz fehlgeschlagen.", "failed_note": "Verschieben der Notiz fehlgeschlagen.",
"failed_folder": "Verschieben des Ordners fehlgeschlagen." "failed_folder": "Verschieben des Ordners fehlgeschlagen.",
"failed_media": "Verschieben der Mediendatei fehlgeschlagen."
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Incorrect password. Please try again." "error_incorrect_password": "Incorrect password. Please try again."
}, },
"images": { "media": {
"confirm_delete": "Delete image \"{{name}}\"?", "confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload image", "upload_failed": "Failed to upload file",
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP", "no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Failed to move note.", "failed_note": "Failed to move note.",
"failed_folder": "Failed to move folder." "failed_folder": "Failed to move folder.",
"failed_media": "Failed to move media file."
}, },
"search": { "search": {

View File

@ -44,6 +44,7 @@
"no_notes": "No notes yet. Create your first note!", "no_notes": "No notes yet. Create your first note!",
"no_notes_yet": "No notes or folders yet.", "no_notes_yet": "No notes or folders yet.",
"create_first": "Create your first note or folder!", "create_first": "Create your first note or folder!",
"drop_to_root": "Drop here for root",
"no_favorites": "No favorites yet", "no_favorites": "No favorites yet",
"no_results": "No results found", "no_results": "No results found",
"expand_all": "Expand all folders", "expand_all": "Expand all folders",
@ -194,16 +195,17 @@
"error_incorrect_password": "Incorrect password. Please try again." "error_incorrect_password": "Incorrect password. Please try again."
}, },
"images": { "media": {
"confirm_delete": "Delete image \"{{name}}\"?", "confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload image", "upload_failed": "Failed to upload file",
"no_valid_files": "No valid image files found. Supported formats: JPG, PNG, GIF, WEBP", "no_valid_files": "No valid files found. Supported: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Supports JPG, PNG, GIF, WebP (max 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Failed to move note.", "failed_note": "Failed to move note.",
"failed_folder": "Failed to move folder." "failed_folder": "Failed to move folder.",
"failed_media": "Failed to move media file."
}, },
"search": { "search": {

View File

@ -44,6 +44,7 @@
"no_notes": "Aún no hay notas. ¡Crea tu primera nota!", "no_notes": "Aún no hay notas. ¡Crea tu primera nota!",
"no_notes_yet": "Aún no hay notas ni carpetas.", "no_notes_yet": "Aún no hay notas ni carpetas.",
"create_first": "¡Crea tu primera nota o carpeta!", "create_first": "¡Crea tu primera nota o carpeta!",
"drop_to_root": "Soltar aquí para raíz",
"no_favorites": "Aún no hay favoritos", "no_favorites": "Aún no hay favoritos",
"no_results": "No se encontraron resultados", "no_results": "No se encontraron resultados",
"expand_all": "Expandir todas las carpetas", "expand_all": "Expandir todas las carpetas",
@ -194,16 +195,17 @@
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo." "error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
}, },
"images": { "media": {
"confirm_delete": "¿Eliminar imagen \"{{name}}\"?", "confirm_delete": "¿Eliminar \"{{name}}\"?",
"upload_failed": "Error al subir imagen", "upload_failed": "Error al subir archivo",
"no_valid_files": "No se encontraron archivos de imagen válidos. Formatos soportados: JPG, PNG, GIF, WEBP", "no_valid_files": "No se encontraron archivos válidos. Soporta: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Soporta JPG, PNG, GIF, WebP (máx 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Error al mover la nota.", "failed_note": "Error al mover la nota.",
"failed_folder": "Error al mover la carpeta." "failed_folder": "Error al mover la carpeta.",
"failed_media": "Error al mover el archivo multimedia."
}, },
"search": { "search": {

View File

@ -44,6 +44,7 @@
"no_notes": "Pas encore de notes. Créez votre première note !", "no_notes": "Pas encore de notes. Créez votre première note !",
"no_notes_yet": "Pas encore de notes ni de dossiers.", "no_notes_yet": "Pas encore de notes ni de dossiers.",
"create_first": "Créez votre première note ou dossier !", "create_first": "Créez votre première note ou dossier !",
"drop_to_root": "Déposer ici pour la racine",
"no_favorites": "Pas encore de favoris", "no_favorites": "Pas encore de favoris",
"no_results": "Aucun résultat trouvé", "no_results": "Aucun résultat trouvé",
"expand_all": "Développer tous les dossiers", "expand_all": "Développer tous les dossiers",
@ -194,16 +195,17 @@
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer." "error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
}, },
"images": { "media": {
"confirm_delete": "Supprimer l'image \"{{name}}\" ?", "confirm_delete": "Supprimer \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement de l'image", "upload_failed": "Échec du téléchargement du fichier",
"no_valid_files": "Aucun fichier image valide trouvé. Formats supportés : JPG, PNG, GIF, WEBP", "no_valid_files": "Aucun fichier valide trouvé. Supporté : JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Supporte JPG, PNG, GIF, WebP (max 10 Mo)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Échec du déplacement de la note.", "failed_note": "Échec du déplacement de la note.",
"failed_folder": "Échec du déplacement du dossier." "failed_folder": "Échec du déplacement du dossier.",
"failed_media": "Échec du déplacement du fichier multimédia."
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Password errata. Riprova." "error_incorrect_password": "Password errata. Riprova."
}, },
"images": { "media": {
"confirm_delete": "Eliminare l'immagine \"{{name}}\"?", "confirm_delete": "Eliminare \"{{name}}\"?",
"upload_failed": "Caricamento immagine fallito", "upload_failed": "Caricamento file fallito",
"no_valid_files": "Nessun file immagine valido. Formati supportati: JPG, PNG, GIF, WEBP", "no_valid_files": "Nessun file valido trovato. Supportati: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Supporta JPG, PNG, GIF, WebP (max 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Impossibile spostare la nota.", "failed_note": "Impossibile spostare la nota.",
"failed_folder": "Impossibile spostare la cartella." "failed_folder": "Impossibile spostare la cartella.",
"failed_media": "Impossibile spostare il file multimediale."
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。" "error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。"
}, },
"images": { "media": {
"confirm_delete": "画像「{{name}}」を削除しますか?", "confirm_delete": "「{{name}}」を削除しますか?",
"upload_failed": "画像のアップロードに失敗しました", "upload_failed": "ファイルのアップロードに失敗しました",
"no_valid_files": "有効な画像ファイルが見つかりません。対応形式: JPG, PNG, GIF, WEBP", "no_valid_files": "有効なファイルが見つかりません。対応: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP対応最大10MB" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "ノートの移動に失敗しました。", "failed_note": "ノートの移動に失敗しました。",
"failed_folder": "フォルダの移動に失敗しました。" "failed_folder": "フォルダの移動に失敗しました。",
"failed_media": "メディアファイルの移動に失敗しました。"
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Неверный пароль. Попробуйте снова." "error_incorrect_password": "Неверный пароль. Попробуйте снова."
}, },
"images": { "media": {
"confirm_delete": "Удалить изображение «{{name}}»?", "confirm_delete": "Удалить «{{name}}»?",
"upload_failed": "Не удалось загрузить изображение", "upload_failed": "Не удалось загрузить файл",
"no_valid_files": "Не найдено подходящих изображений. Поддерживаемые форматы: JPG, PNG, GIF, WEBP", "no_valid_files": "Не найдено подходящих файлов. Поддерживает: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Поддерживает JPG, PNG, GIF, WebP (макс. 10МБ)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Не удалось переместить заметку.", "failed_note": "Не удалось переместить заметку.",
"failed_folder": "Не удалось переместить папку." "failed_folder": "Не удалось переместить папку.",
"failed_media": "Не удалось переместить медиафайл."
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova." "error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova."
}, },
"images": { "media": {
"confirm_delete": "Izbrišem sliko \"{{name}}\"?", "confirm_delete": "Izbrišem \"{{name}}\"?",
"upload_failed": "Nalaganje slike ni uspelo", "upload_failed": "Nalaganje datoteke ni uspelo",
"no_valid_files": "Ni najdenih veljavnih slikovnih datotek. Podprti formati: JPG, PNG, GIF, WEBP", "no_valid_files": "Ni najdenih veljavnih datotek. Podprto: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "Podpira JPG, PNG, GIF, WebP (največ 10MB)" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "Napaka pri premikanju zapisa.", "failed_note": "Napaka pri premikanju zapisa.",
"failed_folder": "Napaka pri premikanju mape." "failed_folder": "Napaka pri premikanju mape.",
"failed_media": "Napaka pri premikanju medijske datoteke."
}, },
"search": { "search": {

View File

@ -194,16 +194,17 @@
"error_incorrect_password": "密码错误。请重试。" "error_incorrect_password": "密码错误。请重试。"
}, },
"images": { "media": {
"confirm_delete": "删除图片 \"{{name}}\"", "confirm_delete": "删除 \"{{name}}\"",
"upload_failed": "上传图片失败", "upload_failed": "上传文件失败",
"no_valid_files": "未找到有效的图片文件。支持的格式JPG、PNG、GIF、WEBP", "no_valid_files": "未找到有效文件。支持JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "支持 JPG、PNG、GIF、WebP最大 10MB" "formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
}, },
"move": { "move": {
"failed_note": "移动笔记失败。", "failed_note": "移动笔记失败。",
"failed_folder": "移动文件夹失败。" "failed_folder": "移动文件夹失败。",
"failed_media": "移动媒体文件失败。"
}, },
"search": { "search": {

3
run.py
View File

@ -73,7 +73,8 @@ def main():
"backend.main:app", "backend.main:app",
"--reload", "--reload",
"--host", "0.0.0.0", "--host", "0.0.0.0",
"--port", port "--port", port,
"--timeout-graceful-shutdown", "2"
]) ])
if __name__ == "__main__": if __name__ == "__main__":