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')"
# 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,68 @@
"""
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.
"""
import base64
import re
from pathlib import Path
from typing import Optional
from typing import Optional, Tuple
import mimetypes
def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL."""
if not image_path.exists() or not image_path.is_file():
# Media type detection
MEDIA_EXTENSIONS = {
'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
# Get MIME type
mime_type, _ = mimetypes.guess_type(str(image_path))
if not mime_type or not mime_type.startswith('image/'):
mime_type, _ = mimetypes.guess_type(str(media_path))
if not mime_type:
return None
# Determine media type
media_type = get_media_type(media_path.name)
if not media_type:
return None
try:
with open(image_path, 'rb') as f:
image_data = f.read()
base64_data = base64.b64encode(image_data).decode('utf-8')
return f"data:{mime_type};base64,{base64_data}"
with open(media_path, 'rb') as f:
media_data = f.read()
base64_data = base64.b64encode(media_data).decode('utf-8')
return (f"data:{mime_type};base64,{base64_data}", media_type)
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
@ -57,22 +92,22 @@ def strip_frontmatter(content: str) -> str:
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.
"""
# Common locations to search for images (fast path)
# Common locations to search for media (fast path)
search_paths = [
note_folder / image_name, # Same folder as note
note_folder / '_attachments' / image_name, # Note's _attachments folder
notes_dir / '_attachments' / image_name, # Root _attachments folder
note_folder / media_name, # Same folder as note
note_folder / '_attachments' / media_name, # Note's _attachments folder
notes_dir / '_attachments' / media_name, # Root _attachments folder
]
# Also search in parent folders' _attachments (for nested notes)
current = note_folder
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
for path in search_paths:
@ -86,11 +121,11 @@ def find_image_in_attachments(image_name: str, note_folder: Path, notes_dir: Pat
continue
# 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:
for attachment_folder in notes_dir.rglob('_attachments'):
if attachment_folder.is_dir():
candidate = attachment_folder / image_name
candidate = attachment_folder / media_name
if candidate.exists() and candidate.is_file():
try:
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
def embed_images_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""
Find all image references in markdown and embed them as base64.
Handles:
- Standard markdown images: ![alt](path)
- Wikilink images: ![[image.png]] or ![[image.png|alt text]]
"""
# 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)
# First, handle wikilink images: ![[image.png]] or ![[image.png|alt text]]
wikilink_img_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink_image(match):
image_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else image_name.split('/')[-1].rsplit('.', 1)[0]
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('"', '"')
# Find the image
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
if resolved_path:
base64_url = get_image_as_base64(resolved_path)
if base64_url:
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})'
# Image not found, convert to placeholder
return f'![{alt_text}]()'
markdown_content = re.sub(wikilink_img_pattern, replace_wikilink_image, markdown_content)
def embed_media_as_base64(markdown_content: str, note_folder: Path, notes_dir: Path) -> str:
"""
Find all media references in markdown and embed them as base64.
Handles:
- Standard markdown images: ![alt](path)
- 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 media: ![[file.png]] or ![[file.mp3|alt text]]
wikilink_pattern = r'!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]'
def replace_wikilink_media(match):
media_name = match.group(1).strip()
alt_text = match.group(2).strip() if match.group(2) else media_name.split('/')[-1].rsplit('.', 1)[0]
# Find the media file
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if resolved_path:
result = get_media_as_base64(resolved_path)
if result:
base64_url, media_type = result
return generate_media_html(base64_url, media_type, alt_text)
# Media not found, convert to placeholder
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_pattern, replace_wikilink_media, markdown_content)
# Then, handle standard markdown images: ![alt](path)
img_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
def replace_image(match):
def replace_media(match):
alt_text = match.group(1)
img_path = match.group(2)
media_path = match.group(2)
# 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)
# Skip empty paths (from failed wikilink conversion)
if not img_path:
if not media_path:
return match.group(0)
# Handle /api/images/ paths (convert to filesystem paths)
if img_path.startswith('/api/images/'):
# Strip the /api/images/ prefix to get the relative path within notes_dir
relative_path = img_path[len('/api/images/'):]
# 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()
else:
# Try to resolve the image path relative to note folder
resolved_path = (note_folder / img_path).resolve()
# Try to resolve the media path relative to note folder
resolved_path = (note_folder / media_path).resolve()
# If not found, try the attachment search
if not resolved_path.exists():
# Extract just the filename and search
image_name = Path(img_path).name
resolved_path = find_image_in_attachments(image_name, note_folder, notes_dir)
media_name = Path(media_path).name
resolved_path = find_media_in_attachments(media_name, note_folder, notes_dir)
if not resolved_path:
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)
# Get base64 data
base64_url = get_image_as_base64(resolved_path)
if base64_url:
return f'![{alt_text}]({base64_url})'
result = get_media_as_base64(resolved_path)
if result:
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)
markdown_content = re.sub(img_pattern, replace_image, markdown_content)
markdown_content = re.sub(img_pattern, replace_media, 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:
"""
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"))
@api_router.get("/images/{image_path:path}", tags=["Images"])
async def get_image(image_path: str):
@api_router.get("/media/{media_path:path}", tags=["Media"])
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:
from backend.utils import ALL_MEDIA_EXTENSIONS
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
if not validate_path_security(notes_dir, full_path):
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():
raise HTTPException(status_code=404, detail="Image not found")
raise HTTPException(status_code=404, detail="File not found")
# Validate it's an image file
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
if full_path.suffix.lower() not in allowed_extensions:
raise HTTPException(status_code=400, detail="Not an image file")
# Validate it's an allowed media file
if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS:
raise HTTPException(status_code=400, detail="Not an allowed media file")
# Return the file
return FileResponse(full_path)
except HTTPException:
raise
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")
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.
Returns the relative path to the image for markdown linking.
Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
Returns the relative path for markdown linking.
"""
try:
# Validate file type
allowed_types = {'image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp'}
allowed_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
from backend.utils import ALL_MEDIA_EXTENSIONS, get_media_type
# 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
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(
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
file_data = await file.read()
# Validate file size (10MB max)
max_size = 10 * 1024 * 1024 # 10MB in bytes
# Validate file size - different limits for different types
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:
raise HTTPException(
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
image_path = save_uploaded_image(
# Save the file (reusing image save function - it works for any file)
file_path = save_uploaded_image(
config['storage']['notes_dir'],
note_path,
file.filename,
file_data
)
if not image_path:
raise HTTPException(status_code=500, detail="Failed to save image")
if not file_path:
raise HTTPException(status_code=500, detail="Failed to save file")
return {
"success": True,
"path": image_path,
"filename": Path(image_path).name,
"message": "Image uploaded successfully"
"path": file_path,
"filename": Path(file_path).name,
"type": media_type,
"message": f"{media_type.capitalize() if media_type else 'File'} uploaded successfully"
}
except HTTPException:
raise
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"])

View File

@ -486,38 +486,62 @@ def save_uploaded_image(notes_dir: str, note_path: str, filename: str, file_data
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]:
"""
Get all images from attachments directories.
Returns list of image dictionaries with metadata.
Get all media files (images, audio, video, documents) from attachments directories.
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)
# Common image extensions
image_extensions = {'.jpg', '.jpeg', '.png', '.gif', '.webp'}
# Find all attachments directories
for attachments_dir in notes_path.rglob("_attachments"):
if not attachments_dir.is_dir():
continue
# Find all images in this attachments directory
for image_file in attachments_dir.iterdir():
if image_file.is_file() and image_file.suffix.lower() in image_extensions:
relative_path = image_file.relative_to(notes_path)
stat = image_file.stat()
# Find all media files in this attachments directory
for media_file in attachments_dir.iterdir():
if media_file.is_file():
media_type = get_media_type(media_file.name)
if media_type:
relative_path = media_file.relative_to(notes_path)
stat = media_file.stat()
images.append({
"name": image_file.name,
media_files.append({
"name": media_file.name,
"path": str(relative_path.as_posix()),
"folder": str(relative_path.parent.as_posix()),
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
"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]:

View File

@ -81,66 +81,71 @@ Content-Type: application/json
}
```
## 🖼️ Images
## 🎬 Media
### Get Image
### Get Media
```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:**
```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:
- The image path is within the notes directory (prevents directory traversal)
- The file exists and is a valid image format
- The media path is within the notes directory (prevents directory traversal)
- The file exists and is a valid media format
- The requesting user is authenticated (if auth is enabled)
### Upload Image
### Upload Media
```http
POST /api/upload-image
POST /api/upload-media
Content-Type: multipart/form-data
file: <image file>
file: <media file>
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
**Maximum size:** 10MB
**Supported formats & size limits:**
| 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:**
```json
{
"success": true,
"path": "folder/_attachments/image-20240417093343.png",
"filename": "image-20240417093343.png",
"message": "Image uploaded successfully"
"path": "folder/_attachments/media-20240417093343.png",
"filename": "media-20240417093343.png",
"message": "Media uploaded successfully"
}
```
**Example (using curl):**
```bash
curl -X POST http://localhost:8000/api/upload-image \
-F "file=@/path/to/image.png" \
curl -X POST http://localhost:8000/api/upload-media \
-F "file=@/path/to/file.mp3" \
-F "note_path=folder/mynote.md"
```
**Windows 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:**
- Images are stored in `_attachments` folders relative to the note's location
- Filenames are automatically timestamped (e.g., `image-20240417093343.png`)
- Images appear in the sidebar navigation and can be viewed/deleted directly
- Drag & drop images into the editor automatically uploads and inserts markdown
- All image access requires authentication when security is enabled
- Media is stored in `_attachments` folders relative to the note's location
- Filenames are automatically timestamped (e.g., `media-20240417093343.mp3`)
- Media appears in the sidebar navigation and can be viewed/deleted directly
- Drag & drop files into the editor automatically uploads and inserts markdown
- All media access requires authentication when security is enabled
## 📁 Folders

View File

@ -15,10 +15,14 @@
- **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))
### Image Support
- **Drag & drop upload** - Drop images from your file system directly into the editor
### Media Support
- **Drag & drop upload** - Drop files from your file system directly into the editor
- **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
- **Folder hierarchy** - Organize notes in nested folders

View File

@ -164,9 +164,9 @@ function noteApp() {
byEndPath: new Map(), // '/filename' and '/filename.md' -> true
},
// Image lookup map for O(1) image wikilink resolution (built on loadNotes)
// Maps image filename (case-insensitive) -> full path
_imageLookup: new Map(),
// Media lookup map for O(1) media wikilink resolution (built on loadNotes)
// Maps media filename (case-insensitive) -> full path
_mediaLookup: new Map(),
// Preview rendering debounce
_previewDebounceTimeout: null,
@ -422,8 +422,9 @@ function noteApp() {
// Mermaid state cache
lastMermaidTheme: null,
// Image viewer state
currentImage: '',
// Media viewer state
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)
_domCache: {
@ -1038,7 +1039,7 @@ function noteApp() {
this._noteLookup.byName.clear();
this._noteLookup.byNameLower.clear();
this._noteLookup.byEndPath.clear();
this._imageLookup.clear();
this._mediaLookup.clear();
for (const note of this.notes) {
const path = note.path;
@ -1046,12 +1047,12 @@ function noteApp() {
const name = note.name;
const nameLower = name.toLowerCase();
// Handle images separately - build image lookup map
if (note.type === 'image') {
// Handle media files separately - build media lookup map
if (note.type !== 'note') {
// Map filename (case-insensitive) to full path
// First match wins if there are duplicates
if (!this._imageLookup.has(nameLower)) {
this._imageLookup.set(nameLower, path);
if (!this._mediaLookup.has(nameLower)) {
this._mediaLookup.set(nameLower, path);
}
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
resolveImageWikilink(imageName) {
const nameLower = imageName.toLowerCase();
return this._imageLookup.get(nameLower) || null;
resolveMediaWikilink(mediaName) {
const nameLower = mediaName.toLowerCase();
return this._mediaLookup.get(nameLower) || null;
},
// Load all tags
@ -1782,14 +1783,14 @@ function noteApp() {
},
handleItemHover(el, isEnter) {
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';
}
},
handleDeleteItemClick(el, event) {
event.stopPropagation();
if (el.dataset.type === 'image') {
this.deleteImage(el.dataset.path);
this.deleteMedia(el.dataset.path);
} else {
this.deleteNote(el.dataset.path, el.dataset.name);
}
@ -1893,16 +1894,16 @@ function noteApp() {
// Then, render notes and images in this folder (after subfolders)
if (folder.notes && folder.notes.length > 0) {
folder.notes.forEach(note => {
// Check if it's an image or a note
const isImage = note.type === 'image';
// Check if it's a media file or a note
const isMediaFile = note.type !== 'note';
const isCurrentNote = this.currentNote === note.path;
const isCurrentImage = this.currentImage === note.path;
const isCurrent = isImage ? isCurrentImage : isCurrentNote;
const isCurrentMedia = this.currentMedia === note.path;
const isCurrent = isMediaFile ? isCurrentMedia : isCurrentNote;
// Different icon for images, share icon for shared notes
const isShared = !isImage && this.isNoteShared(note.path);
// Icon based on media type, share icon for shared notes
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 icon = isImage ? '🖼️' : '';
const icon = this.getMediaIcon(note.type);
html += `
<div
@ -1914,7 +1915,7 @@ function noteApp() {
ondragend="window.$root.onNoteDragEnd()"
onclick="window.$root.handleItemClick(this)"
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)"
onmouseout="window.$root.handleItemHover(this, false)"
>
@ -1926,7 +1927,7 @@ function noteApp() {
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"
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">
<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
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 isImage = item && item.type === 'image';
const isMediaFile = item && item.type !== 'note';
// Set unified drag state
this.draggedItem = {
path: notePath,
type: isImage ? 'image' : 'note'
type: item ? item.type : 'note'
};
// For notes, also set legacy draggedNote for folder move logic
if (!isImage) {
if (!isMediaFile) {
this.draggedNote = notePath;
// Make drag image semi-transparent
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) {
event.preventDefault();
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) {
await this.handleImageDrop(event);
await this.handleMediaDrop(event);
return;
}
// Otherwise, handle note/image link drop from sidebar
// Otherwise, handle note/media link drop from sidebar
if (!this.draggedItem) return;
const notePath = this.draggedItem.path;
const isImage = this.draggedItem.type === 'image';
const isMediaFile = this.draggedItem.type !== 'note';
let link;
if (isImage) {
// For images, use wiki-style link (resolves by filename, never breaks)
if (isMediaFile) {
// For media files (images, audio, video, PDF), use wiki-style embed link
const filename = notePath.split('/').pop();
link = `![[${filename}]]`;
} else {
@ -2192,22 +2193,30 @@ function noteApp() {
this.draggedItem = null;
},
// Handle image files dropped into editor
async handleImageDrop(event) {
// Handle media files dropped into editor
async handleMediaDrop(event) {
if (!this.currentNote) {
alert(this.t('notes.open_first'));
return;
}
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) {
alert(this.t('images.no_valid_files'));
// Filter for allowed media types
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;
}
@ -2216,27 +2225,27 @@ function noteApp() {
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY);
if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
// Upload each image
for (const file of imageFiles) {
// Upload each media file
for (const file of mediaFiles) {
try {
const imagePath = await this.uploadImage(file, this.currentNote);
if (imagePath) {
await this.insertImageMarkdown(imagePath, file.name, cursorPos);
const mediaPath = await this.uploadMedia(file, this.currentNote);
if (mediaPath) {
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
}
} catch (error) {
ErrorHandler.handle(`upload image ${file.name}`, error);
ErrorHandler.handle(`upload file ${file.name}`, error);
}
}
},
// Upload an image file
async uploadImage(file, notePath) {
// Upload a media file (image, audio, video, PDF)
async uploadMedia(file, notePath) {
const formData = new FormData();
formData.append('file', file);
formData.append('note_path', notePath);
try {
const response = await fetch('/api/upload-image', {
const response = await fetch('/api/upload-media', {
method: 'POST',
body: formData
});
@ -2253,13 +2262,13 @@ function noteApp() {
}
},
// Insert image markdown at cursor position using wiki-style syntax
// This ensures image links don't break when notes are moved
async insertImageMarkdown(imagePath, altText, cursorPos) {
// Insert media markdown at cursor position using wiki-style syntax
// This ensures media links don't break when notes are moved
async insertMediaMarkdown(mediaPath, altText, cursorPos) {
// 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
const filenameWithoutExt = filename.replace(/\.[^/.]+$/, '');
const altWithoutExt = altText.replace(/\.[^/.]+$/, '');
@ -2281,7 +2290,7 @@ function noteApp() {
this.autoSave();
},
// Handle paste event for clipboard images
// Handle paste event for clipboard media (images)
async handlePaste(event) {
if (!this.currentNote) return;
@ -2305,77 +2314,112 @@ function noteApp() {
// Create a File from the blob
const file = new File([blob], filename, { type: item.type });
const imagePath = await this.uploadImage(file, this.currentNote);
if (imagePath) {
await this.insertImageMarkdown(imagePath, filename, cursorPos);
const mediaPath = await this.uploadMedia(file, this.currentNote);
if (mediaPath) {
await this.insertMediaMarkdown(mediaPath, filename, cursorPos);
}
} 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 = '') {
this.showGraph = false;
if (type === 'image' || path.match(/\.(png|jpg|jpeg|gif|webp)$/i)) {
this.viewImage(path);
// Check if it's a media file by type or extension
const mediaType = type !== 'note' ? type : this.getMediaType(path);
if (mediaType && mediaType !== 'note') {
this.viewMedia(path, mediaType);
} else {
this.loadNote(path, true, searchHighlight);
}
},
// View an image in the main pane
viewImage(imagePath, updateHistory = true) {
// View a media file (image, audio, video, PDF) in the main pane
viewMedia(mediaPath, mediaType = null, updateHistory = true) {
this.showGraph = false; // Ensure graph is closed
this.currentNote = '';
this.currentNoteName = '';
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.viewMode = 'preview'; // Use preview mode to show image
this.viewMode = 'preview'; // Use preview mode to show media
// Update browser tab title for image
const imageName = imagePath.split('/').pop();
document.title = `${imageName} - ${this.appName}`;
// Update browser tab title
const fileName = mediaPath.split('/').pop();
document.title = `${fileName} - ${this.appName}`;
// Update browser URL
if (updateHistory) {
// 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(
{ imagePath: imagePath },
{ mediaPath: mediaPath },
'',
`/${encodedPath}`
);
}
},
// Delete an image
async deleteImage(imagePath) {
const filename = imagePath.split('/').pop();
if (!confirm(this.t('images.confirm_delete', { name: filename }))) return;
// Backward compatibility alias
viewImage(mediaPath, updateHistory = true) {
this.viewMedia(mediaPath, 'image', updateHistory);
},
// 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 {
const response = await fetch(`/api/notes/${encodeURIComponent(imagePath)}`, {
const response = await fetch(`/api/notes/${encodeURIComponent(mediaPath)}`, {
method: 'DELETE'
});
if (response.ok) {
await this.loadNotes(); // Refresh tree
// Clear viewer if deleting currently viewed image
if (this.currentImage === imagePath) {
this.currentImage = '';
// Clear viewer if deleting currently viewed media
if (this.currentMedia === mediaPath) {
this.currentMedia = '';
}
} else {
throw new Error('Failed to delete image');
throw new Error('Failed to delete media file');
}
} catch (error) {
ErrorHandler.handle('delete image', error);
ErrorHandler.handle('delete media', error);
}
},
@ -2655,7 +2699,7 @@ function noteApp() {
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = '';
this.noteContent = '';
this.currentImage = '';
this.currentMedia = '';
document.title = this.appName;
return;
}
@ -2669,7 +2713,7 @@ function noteApp() {
this._cachedRenderedHTML = '';
this.noteContent = data.content;
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
// Update browser tab title
@ -3944,20 +3988,20 @@ function noteApp() {
return `\x00CODEBLOCK${codeBlocks.length - 1}\x00`;
});
// Step 2: Convert image wikilinks FIRST: ![[image.png]] or ![[image.png|alt text]]
// Must be before note wikilinks to prevent [[image.png]] from being matched first
// Step 2: Convert media wikilinks FIRST: ![[file.png]] or ![[file.png|alt text]]
// Must be before note wikilinks to prevent [[file.png]] from being matched first
contentToRender = contentToRender.replace(
/!\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g,
(match, imageName, altText) => {
const filename = imageName.trim();
(match, mediaName, altText) => {
const filename = mediaName.trim();
const alt = altText ? altText.trim() : filename.replace(/\.[^/.]+$/, '');
// Resolve image path using O(1) lookup
const imagePath = self.resolveImageWikilink(filename);
// Resolve media path using O(1) lookup
const mediaPath = self.resolveMediaWikilink(filename);
if (imagePath) {
if (mediaPath) {
// URL-encode path segments for the API
const encodedPath = imagePath.split('/').map(segment => {
const encodedPath = mediaPath.split('/').map(segment => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch (e) {
@ -3966,12 +4010,27 @@ function noteApp() {
}).join('/');
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;');
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
// Also convert non-image media (audio, video, PDF) to appropriate elements
const images = tempDiv.querySelectorAll('img');
images.forEach(img => {
const src = img.getAttribute('src');
let src = img.getAttribute('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
if (!src.startsWith('http://') && !src.startsWith('https://') &&
!src.startsWith('//') && !src.startsWith('/api/images/') &&
!src.startsWith('//') && !src.startsWith('/api/media/') &&
!src.startsWith('data:')) {
// 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 => {
try {
return encodeURIComponent(decodeURIComponent(segment));
@ -4063,13 +4122,39 @@ function noteApp() {
return encodeURIComponent(segment);
}
}).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');
if (altText) {
// Set title attribute to show alt text on hover
img.setAttribute('title', altText);
}
});
@ -4892,7 +4977,7 @@ function noteApp() {
const encodedPath = match[1];
try {
// Fetch the image
const imgResponse = await fetch(`/api/images/${encodedPath}`);
const imgResponse = await fetch(`/api/media/${encodedPath}`);
if (imgResponse.ok) {
const blob = await imgResponse.blob();
// Convert to base64 data URL
@ -5417,7 +5502,7 @@ function noteApp() {
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentImage = '';
this.currentMedia = '';
this.outline = [];
document.title = this.appName;
@ -5439,7 +5524,7 @@ function noteApp() {
this.currentNote = '';
this.currentNoteName = '';
this.noteContent = '';
this.currentImage = '';
this.currentMedia = '';
this.outline = [];
this.mobileSidebarOpen = false;
document.title = this.appName;
@ -5462,7 +5547,7 @@ function noteApp() {
// Mobile files/home tab - context-aware behavior
mobileFilesTabClick() {
if (this.currentNote || this.currentImage || this.showGraph) {
if (this.currentNote || this.currentMedia || this.showGraph) {
// Viewing content → go home
this.goHome();
} else {

View File

@ -444,6 +444,59 @@
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 */
.markdown-preview input[type="checkbox"] {
margin-right: 0.5rem;
@ -1451,19 +1504,19 @@
@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'"
: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 && currentMedia !== note.path) $el.style.backgroundColor='var(--bg-hover)'"
@mouseout="if(currentNote !== note.path && currentMedia !== 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="getMediaIcon(note.type)"></span>
<span x-text="note.name"></span>
</span>
<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"
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">
<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>
</div>
<template x-if="!currentNote && !currentImage">
<template x-if="!currentNote && !currentMedia">
<!-- Notes Homepage -->
<div class="flex-1 overflow-y-auto custom-scrollbar" style="background-color: var(--bg-primary);">
<!-- Mobile Menu Button (Homepage) -->
@ -2044,12 +2097,12 @@
>
<!-- Delete 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"
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)'"
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">
<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>
</template>
<template x-if="currentNote || currentImage">
<template x-if="currentNote || currentMedia">
<!-- Editor Area -->
<div class="flex-1 flex flex-col overflow-hidden" :class="{'zen-editor-container': zenMode}" style="background-color: var(--bg-primary);">
<!-- Toolbar -->
@ -2101,7 +2154,7 @@
</button>
<input
type="text"
:value="currentNote ? currentNoteName : (currentImage ? currentImage.split('/').pop() : '')"
:value="currentNote ? currentNoteName : (currentMedia ? currentMedia.split('/').pop() : '')"
@input="if (currentNote) currentNoteName = $event.target.value"
@blur="if (currentNote) renameNote()"
:readonly="!currentNote"
@ -2140,12 +2193,12 @@
<!-- Delete Button -->
<button
@click="currentImage ? deleteImage(currentImage) : deleteCurrentNote()"
@click="currentMedia ? deleteMedia(currentMedia) : deleteCurrentNote()"
class="p-2 rounded-lg"
style="color: var(--error);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
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">
<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;">
<!-- Editor -->
<div
x-show="!currentImage && (viewMode === 'edit' || viewMode === 'split')"
x-show="!currentMedia && (viewMode === 'edit' || viewMode === 'split')"
class="flex flex-col"
style="min-height: 0;"
:style="viewMode === 'split' ? `width: ${editorWidth}%;` : 'width: 100%;'"
@ -2366,7 +2419,7 @@
<!-- Split view resize handle -->
<div
x-show="!currentImage && viewMode === 'split'"
x-show="!currentMedia && viewMode === 'split'"
@mousedown="startSplitResize($event)"
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;"
@ -2376,7 +2429,7 @@
<!-- Preview -->
<div
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentImage"
x-show="(viewMode === 'preview' || viewMode === 'split') && !currentMedia"
class="overflow-y-auto overflow-x-hidden custom-scrollbar"
style="background-color: var(--bg-primary); min-height: 0;"
:style="viewMode === 'split' ? `width: ${100 - editorWidth}%;` : 'width: 100%;'"
@ -2499,17 +2552,55 @@
></div>
</div>
<!-- Image Viewer -->
<template x-if="currentImage">
<!-- Media Viewer (images, audio, video, PDF) -->
<template x-if="currentMedia">
<div
class="flex-1 flex items-center justify-center overflow-auto"
style="background-color: var(--bg-primary); min-height: 0;"
>
<!-- Image -->
<template x-if="currentMediaType === 'image'">
<img
:src="`/api/images/${currentImage}`"
:alt="currentImage.split('/').pop()"
: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>
</template>
</div>
@ -2958,18 +3049,18 @@
<nav class="mobile-bottom-tabs zen-hide" style="display: none;" role="navigation" aria-label="Main navigation">
<button
class="mobile-bottom-tab"
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentImage && !showGraph))}"
:class="{'active': activePanel === 'files' && (mobileSidebarOpen || (!currentNote && !currentMedia && !showGraph))}"
@click="mobileFilesTabClick()"
:aria-label="t('sidebar.files')"
>
<!-- 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>
</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>
</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
class="mobile-bottom-tab relative"

View File

@ -194,11 +194,11 @@
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut."
},
"images": {
"confirm_delete": "Bild \"{{name}}\" löschen?",
"upload_failed": "Bild-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Bilddateien gefunden. Unterstützte Formate: JPG, PNG, GIF, WEBP",
"formats": "Unterstützt JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "\"{{name}}\" löschen?",
"upload_failed": "Datei-Upload fehlgeschlagen",
"no_valid_files": "Keine gültigen Dateien gefunden. Unterstützt: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {

View File

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

View File

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

View File

@ -194,11 +194,11 @@
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo."
},
"images": {
"confirm_delete": "¿Eliminar imagen \"{{name}}\"?",
"upload_failed": "Error al subir imagen",
"no_valid_files": "No se encontraron archivos de imagen válidos. Formatos soportados: JPG, PNG, GIF, WEBP",
"formats": "Soporta JPG, PNG, GIF, WebP (máx 10MB)"
"media": {
"confirm_delete": "¿Eliminar \"{{name}}\"?",
"upload_failed": "Error al subir archivo",
"no_valid_files": "No se encontraron archivos válidos. Soporta: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {

View File

@ -194,11 +194,11 @@
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer."
},
"images": {
"confirm_delete": "Supprimer l'image \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement de l'image",
"no_valid_files": "Aucun fichier image valide trouvé. Formats supportés : JPG, PNG, GIF, WEBP",
"formats": "Supporte JPG, PNG, GIF, WebP (max 10 Mo)"
"media": {
"confirm_delete": "Supprimer \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement du fichier",
"no_valid_files": "Aucun fichier valide trouvé. Supporté : JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {

View File

@ -194,11 +194,11 @@
"error_incorrect_password": "Password errata. Riprova."
},
"images": {
"confirm_delete": "Eliminare l'immagine \"{{name}}\"?",
"upload_failed": "Caricamento immagine fallito",
"no_valid_files": "Nessun file immagine valido. Formati supportati: JPG, PNG, GIF, WEBP",
"formats": "Supporta JPG, PNG, GIF, WebP (max 10MB)"
"media": {
"confirm_delete": "Eliminare \"{{name}}\"?",
"upload_failed": "Caricamento file fallito",
"no_valid_files": "Nessun file valido trovato. Supportati: JPG, PNG, GIF, WebP, MP3, MP4, PDF",
"formats": "JPG, PNG, GIF, WebP, MP3, MP4, PDF"
},
"move": {

View File

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

View File

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

View File

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

View File

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

3
run.py
View File

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