support for more media types
This commit is contained in:
parent
3384d7170a
commit
88302fecd3
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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''
|
||||||
|
|
||||||
|
|
||||||
|
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: 
|
- Standard markdown images: 
|
||||||
- Wikilink images: ![[image.png]] or ![[image.png|alt text]]
|
- Wikilink media: ![[file.png]] or ![[file.mp3|alt text]]
|
||||||
|
|
||||||
|
For images: returns markdown 
|
||||||
|
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''
|
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: 
|
# Then, handle standard markdown images: 
|
||||||
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''
|
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.
|
||||||
|
|
|
||||||
|
|
@ -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"])
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
305
frontend/app.js
305
frontend/app.js
|
|
@ -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, '"');
|
const safeAlt = alt.replace(/"/g, '"');
|
||||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
const safeFilename = filename.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
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, '"');
|
||||||
|
|
||||||
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue