support for more media types

This commit is contained in:
Gamosoft 2026-01-18 16:00:01 +01:00
parent 3384d7170a
commit 88302fecd3
19 changed files with 631 additions and 322 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,36 +1,71 @@
""" """
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 media and styling.
Used by both /api/export (download) and /public (sharing) endpoints. Used by both /api/export (download) and /public (sharing) endpoints.
""" """
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
def get_image_as_base64(image_path: Path) -> Optional[str]: # Media type detection
"""Read an image file and return it as a base64 data URL.""" MEDIA_EXTENSIONS = {
if not image_path.exists() or not image_path.is_file(): 'image': {'.jpg', '.jpeg', '.png', '.gif', '.webp'},
'audio': {'.mp3', '.wav', '.ogg', '.m4a'},
'video': {'.mp4', '.webm'},
'document': {'.pdf'}
}
def get_media_type(filename: str) -> Optional[str]:
"""Determine media type based on file extension."""
ext = Path(filename).suffix.lower()
for media_type, extensions in MEDIA_EXTENSIONS.items():
if ext in extensions:
return media_type
return None
def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]:
"""
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 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
def strip_frontmatter(content: str) -> str: def strip_frontmatter(content: str) -> str:
""" """
Remove YAML frontmatter from markdown content. Remove YAML frontmatter from markdown content.
@ -57,22 +92,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 +121,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 +138,98 @@ 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_html(base64_url: str, media_type: str, alt_text: str) -> str:
"""Generate appropriate HTML for embedded media based on type."""
safe_alt = alt_text.replace('"', '"')
if media_type == 'audio':
return f'''<div class="media-embed media-audio" style="margin:1.5rem 0;padding:1.5rem;background:linear-gradient(135deg,var(--bg-tertiary,#f5f5f5) 0%,var(--bg-secondary,#eee) 100%);border:1px solid var(--border-primary,#ddd);border-radius:0.5rem;display:flex;flex-direction:column;align-items:center;">
<audio controls preload="none" src="{base64_url}" title="{safe_alt}" style="width:100%;max-width:500px;border-radius:2rem;"></audio>
<span style="margin-top:0.75rem;font-size:0.875rem;color:var(--text-secondary,#666);">{safe_alt}</span>
</div>'''
elif media_type == 'video':
return f'''<div class="media-embed media-video" style="margin:1.5rem 0;background:#000;display:flex;justify-content:center;border-radius:0.5rem;overflow:hidden;">
<video controls preload="none" poster="" src="{base64_url}" title="{safe_alt}" style="width:100%;max-width:800px;max-height:450px;"></video>
</div>'''
elif media_type == 'document':
return f'''<div class="media-embed media-pdf" style="margin:1.5rem 0;border:1px solid var(--border-primary,#ddd);border-radius:0.5rem;overflow:hidden;">
<iframe src="{base64_url}" title="{safe_alt}" style="width:100%;height:600px;border:none;background:#525659;"></iframe>
</div>'''
else: # image
return f'![{alt_text}]({base64_url})'
def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
""" """
Find all image references in markdown and embed them as base64. Find all media references in markdown and embed them as base64.
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]]
For images: returns markdown ![alt](base64)
For audio/video/PDF: returns inline HTML
""" """
# 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 # Find the media file
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 resolved_path: if resolved_path:
base64_url = get_image_as_base64(resolved_path) result = get_media_as_base64(resolved_path)
if base64_url: if result:
return f'![{alt_text}]({base64_url})' base64_url, media_type = result
return generate_media_html(base64_url, media_type, alt_text)
# Image not found, convert to placeholder # Media not found, convert to placeholder
return f'![{alt_text}]()' media_type = get_media_type(media_name)
icon = '🎵' if media_type == 'audio' else '🎬' if media_type == 'video' else '📄' if media_type == 'document' else '🖼️'
return f'<span style="color:var(--text-tertiary,#999);opacity:0.7;" title="Media not found">{icon} {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 # Skip external URLs and already-embedded base64
if img_path.startswith(('http://', 'https://', 'data:')): if media_path.startswith(('http://', 'https://', '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) # Handle /api/media/ or legacy /api/images/ paths (convert to filesystem paths)
if img_path.startswith('/api/images/'): if media_path.startswith('/api/media/'):
# Strip the /api/images/ prefix to get the relative path within notes_dir relative_path = media_path[len('/api/media/'):]
relative_path = img_path[len('/api/images/'):] 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
@ -171,18 +241,25 @@ def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir:
return match.group(0) return match.group(0)
# Get base64 data # Get base64 data
base64_url = get_image_as_base64(resolved_path) result = get_media_as_base64(resolved_path)
if base64_url: if result:
return f'![{alt_text}]({base64_url})' base64_url, media_type = result
display_alt = alt_text or resolved_path.stem
return generate_media_html(base64_url, media_type, display_alt)
# Image not found, keep original # Media 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
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
return embed_media_as_base64(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,112 @@ 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("/notes/move", tags=["Notes"]) @api_router.post("/notes/move", tags=["Notes"])

View File

@ -486,38 +486,62 @@ 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 attachments directories.
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
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
# Find all attachments directories # Find all attachments directories
for attachments_dir in notes_path.rglob("_attachments"): for attachments_dir in notes_path.rglob("_attachments"):
if not attachments_dir.is_dir(): if not attachments_dir.is_dir():
continue continue
# Find all images in this attachments directory # Find all media files in this attachments directory
for image_file in attachments_dir.iterdir(): for media_file in attachments_dir.iterdir():
if image_file.is_file() and image_file.suffix.lower() in image_extensions: if media_file.is_file():
relative_path = image_file.relative_to(notes_path) media_type = get_media_type(media_file.name)
stat = image_file.stat() if media_type:
relative_path = media_file.relative_to(notes_path)
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()),
"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,71 @@ 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 | 50 MB |
| Video | MP4, WebM | 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"
``` ```
**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,14 @@
- **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 (max 50MB)
- **Video** - MP4, WebM (max 100MB)
- **Documents** - PDF (max 20MB)
- **In-app viewing** - View all media types directly in the sidebar
### Organization ### Organization
- **Folder hierarchy** - Organize notes in nested folders - **Folder hierarchy** - Organize notes in nested folders

View File

@ -164,9 +164,9 @@ function noteApp() {
byEndPath: new Map(), // '/filename' and '/filename.md' -> true byEndPath: new Map(), // '/filename' and '/filename.md' -> true
}, },
// Image lookup map for O(1) image wikilink resolution (built on loadNotes) // Media lookup map for O(1) media wikilink resolution (built on loadNotes)
// Maps image filename (case-insensitive) -> full path // Maps media filename (case-insensitive) -> full path
_imageLookup: new Map(), _mediaLookup: new Map(),
// Preview rendering debounce // Preview rendering debounce
_previewDebounceTimeout: null, _previewDebounceTimeout: null,
@ -422,8 +422,9 @@ function noteApp() {
// Mermaid state cache // Mermaid state cache
lastMermaidTheme: null, lastMermaidTheme: null,
// Image viewer state // Media viewer state
currentImage: '', currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
currentMediaType: 'image', // 'image', 'audio', 'video', 'document'
// DOM element cache (to avoid repeated querySelector calls) // DOM element cache (to avoid repeated querySelector calls)
_domCache: { _domCache: {
@ -1038,7 +1039,7 @@ function noteApp() {
this._noteLookup.byName.clear(); this._noteLookup.byName.clear();
this._noteLookup.byNameLower.clear(); this._noteLookup.byNameLower.clear();
this._noteLookup.byEndPath.clear(); this._noteLookup.byEndPath.clear();
this._imageLookup.clear(); this._mediaLookup.clear();
for (const note of this.notes) { for (const note of this.notes) {
const path = note.path; const path = note.path;
@ -1046,12 +1047,12 @@ function noteApp() {
const name = note.name; const name = note.name;
const nameLower = name.toLowerCase(); const nameLower = name.toLowerCase();
// Handle images separately - build image lookup map // Handle media files separately - build media lookup map
if (note.type === 'image') { if (note.type !== 'note') {
// Map filename (case-insensitive) to full path // Map filename (case-insensitive) to full path
// First match wins if there are duplicates // First match wins if there are duplicates
if (!this._imageLookup.has(nameLower)) { if (!this._mediaLookup.has(nameLower)) {
this._imageLookup.set(nameLower, path); this._mediaLookup.set(nameLower, path);
} }
continue; continue;
} }
@ -1093,11 +1094,11 @@ function noteApp() {
); );
}, },
// Resolve image wikilink to full path (O(1) lookup) // Resolve media wikilink to full path (O(1) lookup)
// Returns the full path if found, null otherwise // Returns the full path if found, null otherwise
resolveImageWikilink(imageName) { resolveMediaWikilink(mediaName) {
const nameLower = imageName.toLowerCase(); const nameLower = mediaName.toLowerCase();
return this._imageLookup.get(nameLower) || null; return this._mediaLookup.get(nameLower) || null;
}, },
// Load all tags // Load all tags
@ -1782,14 +1783,14 @@ function noteApp() {
}, },
handleItemHover(el, isEnter) { handleItemHover(el, isEnter) {
const path = el.dataset.path; const path = el.dataset.path;
if (path !== this.currentNote && path !== this.currentImage) { if (path !== this.currentNote && path !== this.currentMedia) {
el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent'; el.style.backgroundColor = isEnter ? 'var(--bg-hover)' : 'transparent';
} }
}, },
handleDeleteItemClick(el, event) { handleDeleteItemClick(el, event) {
event.stopPropagation(); event.stopPropagation();
if (el.dataset.type === 'image') { if (el.dataset.type === 'image') {
this.deleteImage(el.dataset.path); this.deleteMedia(el.dataset.path);
} else { } else {
this.deleteNote(el.dataset.path, el.dataset.name); this.deleteNote(el.dataset.path, el.dataset.name);
} }
@ -1893,16 +1894,16 @@ function noteApp() {
// Then, render notes and images in this folder (after subfolders) // Then, render notes and images in this folder (after subfolders)
if (folder.notes && folder.notes.length > 0) { if (folder.notes && folder.notes.length > 0) {
folder.notes.forEach(note => { folder.notes.forEach(note => {
// Check if it's an image or a note // Check if it's a media file or a note
const isImage = note.type === 'image'; const isMediaFile = note.type !== 'note';
const isCurrentNote = this.currentNote === note.path; const isCurrentNote = this.currentNote === note.path;
const isCurrentImage = this.currentImage === note.path; const isCurrentMedia = this.currentMedia === note.path;
const isCurrent = isImage ? isCurrentImage : isCurrentNote; const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote;
// Different icon for images, share icon for shared notes // Icon based on media type, share icon for shared notes
const isShared = !isImage && this.isNoteShared(note.path); const isShared = !isMediaFile && this.isNoteShared(note.path);
const shareIcon = isShared ? '<svg title="Shared" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : ''; const shareIcon = isShared ? '<svg title="Shared" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : '';
const icon = isImage ? '🖼️' : ''; const icon = this.getMediaIcon(note.type);
html += ` html += `
<div <div
@ -1914,7 +1915,7 @@ function noteApp() {
ondragend="window.$root.onNoteDragEnd()" ondragend="window.$root.onNoteDragEnd()"
onclick="window.$root.handleItemClick(this)" onclick="window.$root.handleItemClick(this)"
class="note-item px-2 py-1 text-sm relative" class="note-item px-2 py-1 text-sm relative"
style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isImage ? 'opacity: 0.85;' : ''} cursor: pointer;" style="${isCurrent ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);'} ${isMediaFile ? 'opacity: 0.85;' : ''} cursor: pointer;"
onmouseover="window.$root.handleItemHover(this, true)" onmouseover="window.$root.handleItemHover(this, true)"
onmouseout="window.$root.handleItemHover(this, false)" onmouseout="window.$root.handleItemHover(this, false)"
> >
@ -1926,7 +1927,7 @@ function noteApp() {
onclick="window.$root.handleDeleteItemClick(this, event)" onclick="window.$root.handleDeleteItemClick(this, event)"
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" 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);" style="opacity: 0; color: var(--error);"
title="${isImage ? 'Delete image' : 'Delete note'}" title="${isMediaFile ? 'Delete file' : '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>
@ -2037,18 +2038,18 @@ function noteApp() {
// Drag and drop handlers // Drag and drop handlers
onNoteDragStart(notePath, event) { onNoteDragStart(notePath, event) {
// Check if this is an image // Check if this is a media file
const item = this.notes.find(n => n.path === notePath); const item = this.notes.find(n => n.path === notePath);
const isImage = item && item.type === 'image'; const isMediaFile = item && item.type !== 'note';
// Set unified drag state // Set unified drag state
this.draggedItem = { this.draggedItem = {
path: notePath, path: notePath,
type: isImage ? 'image' : 'note' type: item ? item.type : 'note'
}; };
// For notes, also set legacy draggedNote for folder move logic // For notes, also set legacy draggedNote for folder move logic
if (!isImage) { if (!isMediaFile) {
this.draggedNote = notePath; this.draggedNote = notePath;
// Make drag image semi-transparent // Make drag image semi-transparent
if (event.target) { if (event.target) {
@ -2141,26 +2142,26 @@ function noteApp() {
} }
}, },
// Handle drop into editor to create internal link or upload image // Handle drop into editor to create internal link or upload media
async onEditorDrop(event) { async onEditorDrop(event) {
event.preventDefault(); event.preventDefault();
this.dropTarget = null; this.dropTarget = null;
// Check if files are being dropped (images from file system) // Check if files are being dropped (media from file system)
if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) { if (event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length > 0) {
await this.handleImageDrop(event); await this.handleMediaDrop(event);
return; return;
} }
// Otherwise, handle note/image link drop from sidebar // Otherwise, handle note/media link drop from sidebar
if (!this.draggedItem) return; if (!this.draggedItem) return;
const notePath = this.draggedItem.path; const notePath = this.draggedItem.path;
const isImage = this.draggedItem.type === 'image'; const isMediaFile = this.draggedItem.type !== 'note';
let link; let link;
if (isImage) { if (isMediaFile) {
// For images, use wiki-style link (resolves by filename, never breaks) // For media files (images, audio, video, PDF), use wiki-style embed link
const filename = notePath.split('/').pop(); const filename = notePath.split('/').pop();
link = `![[${filename}]]`; link = `![[${filename}]]`;
} else { } else {
@ -2192,22 +2193,30 @@ function noteApp() {
this.draggedItem = null; this.draggedItem = null;
}, },
// Handle image files dropped into editor // Handle media files dropped into editor
async handleImageDrop(event) { async handleMediaDrop(event) {
if (!this.currentNote) { if (!this.currentNote) {
alert(this.t('notes.open_first')); alert(this.t('notes.open_first'));
return; return;
} }
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
const imageFiles = files.filter(file => {
const type = file.type.toLowerCase();
return type.startsWith('image/') &&
['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'].includes(type);
});
if (imageFiles.length === 0) { // Filter for allowed media types
alert(this.t('images.no_valid_files')); const allowedTypes = [
// 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',
// Documents
'application/pdf'
];
const mediaFiles = files.filter(file => allowedTypes.includes(file.type.toLowerCase()));
if (mediaFiles.length === 0) {
alert(this.t('media.no_valid_files'));
return; return;
} }
@ -2216,27 +2225,27 @@ function noteApp() {
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
// Upload each image // Upload each media file
for (const file of imageFiles) { for (const file of mediaFiles) {
try { try {
const imagePath = await this.uploadImage(file, this.currentNote); const mediaPath = await this.uploadMedia(file, this.currentNote);
if (imagePath) { if (mediaPath) {
await this.insertImageMarkdown(imagePath, file.name, cursorPos); await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
} }
} catch (error) { } catch (error) {
ErrorHandler.handle(`upload image ${file.name}`, error); ErrorHandler.handle(`upload file ${file.name}`, error);
} }
} }
}, },
// Upload an image file // Upload a media file (image, audio, video, PDF)
async uploadImage(file, notePath) { async uploadMedia(file, notePath) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('note_path', notePath); formData.append('note_path', notePath);
try { try {
const response = await fetch('/api/upload-image', { const response = await fetch('/api/upload-media', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@ -2253,13 +2262,13 @@ function noteApp() {
} }
}, },
// Insert image markdown at cursor position using wiki-style syntax // Insert media markdown at cursor position using wiki-style syntax
// This ensures image links don't break when notes are moved // This ensures media links don't break when notes are moved
async insertImageMarkdown(imagePath, altText, cursorPos) { async insertMediaMarkdown(mediaPath, altText, cursorPos) {
// Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png") // Extract just the filename from the path (e.g., "folder/_attachments/image.png" -> "image.png")
const filename = imagePath.split('/').pop(); const filename = mediaPath.split('/').pop();
// Use wiki-style image link: ![[filename.png]] or ![[filename.png|alt text]] // Use wiki-style embed link: ![[filename.png]] or ![[filename.png|alt text]]
// The alt text is optional - only add if different from filename // The alt text is optional - only add if different from filename
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, ''); const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const altWithoutExt = altText.replace(/\.[^/.]+$/, ''); const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
@ -2281,7 +2290,7 @@ function noteApp() {
this.autoSave(); this.autoSave();
}, },
// Handle paste event for clipboard images // Handle paste event for clipboard media (images)
async handlePaste(event) { async handlePaste(event) {
if (!this.currentNote) return; if (!this.currentNote) return;
@ -2305,77 +2314,112 @@ function noteApp() {
// Create a File from the blob // Create a File from the blob
const file = new File([blob], filename, { type: item.type }); const file = new File([blob], filename, { type: item.type });
const imagePath = await this.uploadImage(file, this.currentNote); const mediaPath = await this.uploadMedia(file, this.currentNote);
if (imagePath) { if (mediaPath) {
await this.insertImageMarkdown(imagePath, filename, cursorPos); await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
} }
} catch (error) { } catch (error) {
ErrorHandler.handle('paste image', error); ErrorHandler.handle('paste media', error);
} }
} }
break; // Only handle first image break; // Only handle first media item
} }
} }
}, },
// Open a note or image (unified handler for sidebar/homepage clicks) // Media type detection based on file extension
getMediaType(filename) {
if (!filename) return null;
const ext = filename.split('.').pop().toLowerCase();
const mediaTypes = {
image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
audio: ['mp3', 'wav', 'ogg', 'm4a'],
video: ['mp4', 'webm', 'mov', 'avi'],
document: ['pdf'],
};
for (const [type, extensions] of Object.entries(mediaTypes)) {
if (extensions.includes(ext)) return type;
}
return null;
},
// Get icon for media type
getMediaIcon(type) {
const icons = {
image: '🖼️',
audio: '🎵',
video: '🎬',
document: '📄',
};
return icons[type] || '';
},
// Open a note or media file (unified handler for sidebar/homepage clicks)
openItem(path, type = 'note', searchHighlight = '') { openItem(path, type = 'note', searchHighlight = '') {
this.showGraph = false; this.showGraph = false;
if (type === 'image' || path.match(/\.(png|jpg|jpeg|gif|webp)$/i)) { // Check if it's a media file by type or extension
this.viewImage(path); const mediaType = type !== 'note' ? type : this.getMediaType(path);
if (mediaType && mediaType !== 'note') {
this.viewMedia(path, mediaType);
} else { } else {
this.loadNote(path, true, searchHighlight); this.loadNote(path, true, searchHighlight);
} }
}, },
// View an image in the main pane // View a media file (image, audio, video, PDF) in the main pane
viewImage(imagePath, updateHistory = true) { viewMedia(mediaPath, mediaType = null, updateHistory = true) {
this.showGraph = false; // Ensure graph is closed this.showGraph = false; // Ensure graph is closed
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = imagePath; this.currentMedia = mediaPath; // Reuse currentMedia for all media
this.currentMediaType = mediaType || this.getMediaType(mediaPath) || 'image';
this.shareInfo = null; // Reset share info this.shareInfo = null; // Reset share info
this.viewMode = 'preview'; // Use preview mode to show image this.viewMode = 'preview'; // Use preview mode to show media
// Update browser tab title for image // Update browser tab title
const imageName = imagePath.split('/').pop(); const fileName = mediaPath.split('/').pop();
document.title = `${imageName} - ${this.appName}`; document.title = `${fileName} - ${this.appName}`;
// Update browser URL // Update browser URL
if (updateHistory) { if (updateHistory) {
// Encode each path segment to handle special characters // Encode each path segment to handle special characters
const encodedPath = imagePath.split('/').map(segment => encodeURIComponent(segment)).join('/'); const encodedPath = mediaPath.split('/').map(segment => encodeURIComponent(segment)).join('/');
window.history.pushState( window.history.pushState(
{ imagePath: imagePath }, { mediaPath: mediaPath },
'', '',
`/${encodedPath}` `/${encodedPath}`
); );
} }
}, },
// Delete an image // Backward compatibility alias
async deleteImage(imagePath) { viewImage(mediaPath, updateHistory = true) {
const filename = imagePath.split('/').pop(); this.viewMedia(mediaPath, 'image', updateHistory);
if (!confirm(this.t('images.confirm_delete', { name: filename }))) return; },
// Delete a media file (image, audio, video, PDF)
async deleteMedia(mediaPath) {
const filename = mediaPath.split('/').pop();
if (!confirm(this.t('media.confirm_delete', { name: filename }))) return;
try { try {
const response = await fetch(`/api/notes/${encodeURIComponent(imagePath)}`, { const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
method: 'DELETE' method: 'DELETE'
}); });
if (response.ok) { if (response.ok) {
await this.loadNotes(); // Refresh tree await this.loadNotes(); // Refresh tree
// Clear viewer if deleting currently viewed image // Clear viewer if deleting currently viewed media
if (this.currentImage === imagePath) { if (this.currentMedia === mediaPath) {
this.currentImage = ''; this.currentMedia = '';
} }
} else { } else {
throw new Error('Failed to delete image'); throw new Error('Failed to delete media file');
} }
} catch (error) { } catch (error) {
ErrorHandler.handle('delete image', error); ErrorHandler.handle('delete media', error);
} }
}, },
@ -2655,7 +2699,7 @@ function noteApp() {
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentMedia = '';
document.title = this.appName; document.title = this.appName;
return; return;
} }
@ -2669,7 +2713,7 @@ function noteApp() {
this._cachedRenderedHTML = ''; this._cachedRenderedHTML = '';
this.noteContent = data.content; this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentNoteName = notePath.split('/').pop().replace('.md', '');
this.currentImage = ''; // Clear image viewer when loading a note this.currentMedia = ''; // Clear image viewer when loading a note
this.shareInfo = null; // Reset share info for new note this.shareInfo = null; // Reset share info for new note
// Update browser tab title // Update browser tab title
@ -3944,20 +3988,20 @@ function noteApp() {
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`; return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
}); });
// Step 2: Convert image wikilinks FIRST: ![[image.png]] or ![[image.png|alt text]] // Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
// Must be before note wikilinks to prevent [[image.png]] from being matched first // Must be before note wikilinks to prevent [[file.png]] from being matched first
contentToRender = contentToRender.replace( contentToRender = contentToRender.replace(
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, /!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, imageName, altText) => { (match, mediaName, altText) => {
const filename = imageName.trim(); const filename = mediaName.trim();
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, ''); const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
// Resolve image path using O(1) lookup // Resolve media path using O(1) lookup
const imagePath = self.resolveImageWikilink(filename); const mediaPath = self.resolveMediaWikilink(filename);
if (imagePath) { if (mediaPath) {
// URL-encode path segments for the API // URL-encode path segments for the API
const encodedPath = imagePath.split('/').map(segment => { const encodedPath = mediaPath.split('/').map(segment => {
try { try {
return encodeURIComponent(decodeURIComponent(segment)); return encodeURIComponent(decodeURIComponent(segment));
} catch (e) { } catch (e) {
@ -3966,12 +4010,27 @@ function noteApp() {
}).join('/'); }).join('/');
const safeAlt = alt.replace(/"/g, '&quot;'); const safeAlt = alt.replace(/"/g, '&quot;');
return `<img src="/api/images/${encodedPath}" alt="${safeAlt}" title="${safeAlt}">`; const mediaSrc = `/api/media/${encodedPath}`;
const mediaType = self.getMediaType(filename);
// Return appropriate HTML based on media type
switch (mediaType) {
case 'audio':
return `<div class="media-embed media-audio"><audio controls preload="none" src="${mediaSrc}" title="${safeAlt}"></audio><span class="media-caption">${safeAlt}</span></div>`;
case 'video':
return `<div class="media-embed media-video"><video controls preload="none" poster="" src="${mediaSrc}" title="${safeAlt}"></video></div>`;
case 'document':
return `<div class="media-embed media-pdf"><iframe src="${mediaSrc}" title="${safeAlt}" loading="lazy"></iframe></div>`;
default: // image
return `<img src="${mediaSrc}" alt="${safeAlt}" title="${safeAlt}">`;
}
} }
// Image not found - return broken image indicator // Media not found - return broken indicator
const safeFilename = filename.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const safeFilename = filename.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<span class="wikilink-broken" title="Image not found">🖼️ ${safeFilename}</span>`; const mediaType = self.getMediaType(filename);
const icon = mediaType === 'audio' ? '🎵' : mediaType === 'video' ? '🎬' : mediaType === 'document' ? '📄' : '🖼️';
return `<span class="wikilink-broken" title="Media not found">${icon} ${safeFilename}</span>`;
} }
); );
@ -4045,17 +4104,17 @@ function noteApp() {
}); });
// Find all images and transform paths for display // Find all images and transform paths for display
// Also convert non-image media (audio, video, PDF) to appropriate elements
const images = tempDiv.querySelectorAll('img'); const images = tempDiv.querySelectorAll('img');
images.forEach(img => { images.forEach(img => {
const src = img.getAttribute('src'); let src = img.getAttribute('src');
if (src) { if (src) {
// Transform relative paths to /api/images/ for serving // Transform relative paths to /api/media/ for serving
// Skip external URLs and already-transformed paths // Skip external URLs and already-transformed paths
if (!src.startsWith('http://') && !src.startsWith('https://') && if (!src.startsWith('http://') && !src.startsWith('https://') &&
!src.startsWith('//') && !src.startsWith('/api/images/') && !src.startsWith('//') && !src.startsWith('/api/media/') &&
!src.startsWith('data:')) { !src.startsWith('data:')) {
// URL-encode path segments to handle spaces and special characters // URL-encode path segments to handle spaces and special characters
// Decode first to avoid double-encoding if path already contains %XX sequences
const encodedPath = src.split('/').map(segment => { const encodedPath = src.split('/').map(segment => {
try { try {
return encodeURIComponent(decodeURIComponent(segment)); return encodeURIComponent(decodeURIComponent(segment));
@ -4063,13 +4122,39 @@ function noteApp() {
return encodeURIComponent(segment); return encodeURIComponent(segment);
} }
}).join('/'); }).join('/');
img.setAttribute('src', `/api/images/${encodedPath}`); src = `/api/media/${encodedPath}`;
img.setAttribute('src', src);
}
// Check if this is non-image media and convert to appropriate element
const mediaType = self.getMediaType(src);
const altText = img.getAttribute('alt') || src.split('/').pop().replace(/\.[^/.]+$/, '');
const safeAlt = altText.replace(/"/g, '&quot;');
if (mediaType === 'audio') {
const wrapper = document.createElement('div');
wrapper.className = 'media-embed media-audio';
wrapper.innerHTML = `<audio controls preload="none" src="${src}" title="${safeAlt}"></audio><span class="media-caption">${safeAlt}</span>`;
img.replaceWith(wrapper);
return;
} else if (mediaType === 'video') {
const wrapper = document.createElement('div');
wrapper.className = 'media-embed media-video';
wrapper.innerHTML = `<video controls preload="none" poster="" src="${src}" title="${safeAlt}"></video>`;
img.replaceWith(wrapper);
return;
} else if (mediaType === 'document') {
const wrapper = document.createElement('div');
wrapper.className = 'media-embed media-pdf';
wrapper.innerHTML = `<iframe src="${src}" title="${safeAlt}" loading="lazy"></iframe>`;
img.replaceWith(wrapper);
return;
} }
} }
// For regular images, set title attribute
const altText = img.getAttribute('alt'); const altText = img.getAttribute('alt');
if (altText) { if (altText) {
// Set title attribute to show alt text on hover
img.setAttribute('title', altText); img.setAttribute('title', altText);
} }
}); });
@ -4892,7 +4977,7 @@ function noteApp() {
const encodedPath = match[1]; const encodedPath = match[1];
try { try {
// Fetch the image // Fetch the image
const imgResponse = await fetch(`/api/images/${encodedPath}`); const imgResponse = await fetch(`/api/media/${encodedPath}`);
if (imgResponse.ok) { if (imgResponse.ok) {
const blob = await imgResponse.blob(); const blob = await imgResponse.blob();
// Convert to base64 data URL // Convert to base64 data URL
@ -5417,7 +5502,7 @@ function noteApp() {
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentMedia = '';
this.outline = []; this.outline = [];
document.title = this.appName; document.title = this.appName;
@ -5439,7 +5524,7 @@ function noteApp() {
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
this.noteContent = ''; this.noteContent = '';
this.currentImage = ''; this.currentMedia = '';
this.outline = []; this.outline = [];
this.mobileSidebarOpen = false; this.mobileSidebarOpen = false;
document.title = this.appName; document.title = this.appName;
@ -5462,7 +5547,7 @@ function noteApp() {
// Mobile files/home tab - context-aware behavior // Mobile files/home tab - context-aware behavior
mobileFilesTabClick() { mobileFilesTabClick() {
if (this.currentNote || this.currentImage || this.showGraph) { if (this.currentNote || this.currentMedia || this.showGraph) {
// Viewing content → go home // Viewing content → go home
this.goHome(); this.goHome();
} else { } else {

View File

@ -444,6 +444,59 @@
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 */
.markdown-preview .media-pdf {
border: 1px solid var(--border-primary);
border-radius: 0.5rem;
overflow: hidden;
}
.markdown-preview .media-pdf iframe {
width: 100%;
height: 600px;
border: none;
background: #525659;
}
/* Task lists */ /* Task lists */
.markdown-preview input[type="checkbox"] { .markdown-preview input[type="checkbox"] {
margin-right: 0.5rem; margin-right: 0.5rem;
@ -1451,19 +1504,19 @@
@dragend="onNoteDragEnd()" @dragend="onNoteDragEnd()"
@click="openItem(note.path, note.type)" @click="openItem(note.path, note.type)"
class="note-item px-2 py-1 text-sm relative" 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;')" :style="((note.type !== 'note' ? currentMedia === note.path : currentNote === note.path) ? 'background-color: var(--accent-light); color: var(--accent-primary);' : 'color: var(--text-primary);') + (note.type !== 'note' ? ' opacity: 0.85;' : '') + (draggedNote || draggedFolder ? ' cursor: grabbing;' : ' cursor: pointer;')"
@mouseover="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='var(--bg-hover)'" @mouseover="if(currentNote !== note.path && currentMedia !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if(currentNote !== note.path && currentImage !== note.path) $el.style.backgroundColor='transparent'" @mouseout="if(currentNote !== note.path && currentMedia !== note.path) $el.style.backgroundColor='transparent'"
> >
<span class="truncate" style="display: block; padding-right: 30px;"> <span class="truncate" style="display: block; padding-right: 30px;">
<template x-if="note.type === 'image'">🖼️ </template> <span x-text="getMediaIcon(note.type)"></span>
<span x-text="note.name"></span> <span x-text="note.name"></span>
</span> </span>
<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="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" 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);" style="opacity: 0; color: var(--error);"
: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>
@ -1868,7 +1921,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 +2097,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 +2133,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 +2154,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 +2193,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 +2340,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 +2419,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 +2429,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 +2552,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;"
> >
<img <!-- Image -->
:src="`/api/images/${currentImage}`" <template x-if="currentMediaType === 'image'">
:alt="currentImage.split('/').pop()" <img
style="max-width: 100%; max-height: 100%; object-fit: contain; padding: 2rem;" :src="`/api/media/${currentMedia}`"
/> :alt="currentMedia.split('/').pop()"
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>
@ -2958,18 +3049,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

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

View File

@ -194,11 +194,11 @@
"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": {

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__":